? onChanged;
+
+ const InouCheckbox({
+ super.key,
+ required this.value,
+ required this.label,
+ this.onChanged,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: () => onChanged?.call(!value),
+ child: Row(
+ children: [
+ Checkbox(
+ value: value,
+ onChanged: onChanged,
+ activeColor: InouTheme.accent,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ ),
+ Expanded(
+ child: Text(
+ label,
+ style: InouTheme.bodyMedium.copyWith(color: InouTheme.textMuted),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/design/flutter/widgets/inou_message.dart b/design/flutter/widgets/inou_message.dart
new file mode 100644
index 0000000..f455b5f
--- /dev/null
+++ b/design/flutter/widgets/inou_message.dart
@@ -0,0 +1,69 @@
+// AUTO-GENERATED widget — matches web .error/.info/.success
+import 'package:flutter/material.dart';
+import '../inou_theme.dart';
+
+enum MessageType { error, info, success }
+
+class InouMessage extends StatelessWidget {
+ final String message;
+ final MessageType type;
+
+ const InouMessage({
+ super.key,
+ required this.message,
+ this.type = MessageType.info,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final style = _getStyle();
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: style.background,
+ border: Border.all(color: style.border),
+ borderRadius: BorderRadius.circular(InouTheme.radiusMd),
+ ),
+ child: Text(
+ message,
+ style: InouTheme.bodyMedium.copyWith(color: style.foreground),
+ ),
+ );
+ }
+
+ _MessageStyle _getStyle() {
+ switch (type) {
+ case MessageType.error:
+ return _MessageStyle(
+ background: InouTheme.dangerLight,
+ foreground: InouTheme.danger,
+ border: const Color(0xFFFECACA),
+ );
+ case MessageType.info:
+ return _MessageStyle(
+ background: InouTheme.accentLight,
+ foreground: InouTheme.accent,
+ border: const Color(0xFFFDE68A),
+ );
+ case MessageType.success:
+ return _MessageStyle(
+ background: InouTheme.successLight,
+ foreground: InouTheme.success,
+ border: const Color(0xFFA7F3D0),
+ );
+ }
+ }
+}
+
+class _MessageStyle {
+ final Color background;
+ final Color foreground;
+ final Color border;
+
+ _MessageStyle({
+ required this.background,
+ required this.foreground,
+ required this.border,
+ });
+}
diff --git a/design/flutter/widgets/widgets.dart b/design/flutter/widgets/widgets.dart
new file mode 100644
index 0000000..d8729e2
--- /dev/null
+++ b/design/flutter/widgets/widgets.dart
@@ -0,0 +1,7 @@
+// Barrel file for all inou widgets
+export 'inou_card.dart';
+export 'inou_button.dart';
+export 'inou_badge.dart';
+export 'inou_message.dart';
+export 'inou_input.dart';
+export 'inou_data_row.dart';
diff --git a/design/generate.js b/design/generate.js
new file mode 100644
index 0000000..02b1ef5
--- /dev/null
+++ b/design/generate.js
@@ -0,0 +1,286 @@
+#!/usr/bin/env node
+/**
+ * Design Token Generator
+ * Single source of truth → CSS variables + Flutter theme
+ *
+ * Usage: node generate.js
+ * Outputs:
+ * - tokens.css (CSS custom properties)
+ * - flutter/inou_theme.dart (Flutter ThemeData)
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const tokens = JSON.parse(fs.readFileSync(path.join(__dirname, 'tokens.json'), 'utf8'));
+
+// ============================================
+// CSS GENERATOR
+// ============================================
+function generateCSS(tokens) {
+ const lines = [
+ '/* AUTO-GENERATED from tokens.json — do not edit directly */',
+ '/* Run: node design/generate.js */',
+ '',
+ ':root {'
+ ];
+
+ // Colors
+ for (const [key, value] of Object.entries(tokens.colors)) {
+ const cssVar = `--${camelToKebab(key)}`;
+ lines.push(` ${cssVar}: ${value};`);
+ }
+
+ lines.push('');
+
+ // Spacing
+ for (const [key, value] of Object.entries(tokens.spacing.scale)) {
+ lines.push(` --space-${key}: ${value}px;`);
+ }
+
+ lines.push('');
+
+ // Radii
+ for (const [key, value] of Object.entries(tokens.radii)) {
+ if (key !== 'full') {
+ lines.push(` --radius-${key}: ${value}px;`);
+ } else {
+ lines.push(` --radius-${key}: ${value}px;`);
+ }
+ }
+
+ lines.push('');
+
+ // Layout
+ lines.push(` --max-width: ${tokens.layout.maxWidth}px;`);
+ lines.push(` --max-width-narrow: ${tokens.layout.maxWidthNarrow}px;`);
+ lines.push(` --max-width-form: ${tokens.layout.maxWidthForm}px;`);
+
+ lines.push('}');
+
+ return lines.join('\n');
+}
+
+// ============================================
+// FLUTTER GENERATOR
+// ============================================
+function generateFlutter(tokens) {
+ const lines = [
+ '// AUTO-GENERATED from tokens.json — do not edit directly',
+ '// Run: node design/generate.js',
+ '',
+ "import 'package:flutter/material.dart';",
+ "import 'package:google_fonts/google_fonts.dart';",
+ '',
+ '/// inou Design System',
+ '/// Single source of truth: design/tokens.json',
+ 'class InouTheme {',
+ ' InouTheme._();',
+ '',
+ ' // ============================================',
+ ' // COLORS',
+ ' // ============================================',
+ ];
+
+ // Colors as static constants
+ for (const [key, value] of Object.entries(tokens.colors)) {
+ const hex = value.replace('#', '');
+ const alpha = hex.length === 6 ? 'FF' : '';
+ lines.push(` static const Color ${key} = Color(0x${alpha}${hex.toUpperCase()});`);
+ }
+
+ lines.push('');
+ lines.push(' // Indicator colors (data sections)');
+ for (const [key, value] of Object.entries(tokens.indicators)) {
+ const hex = value.replace('#', '');
+ lines.push(` static const Color indicator${capitalize(key)} = Color(0xFF${hex.toUpperCase()});`);
+ }
+
+ // Spacing
+ lines.push('');
+ lines.push(' // ============================================');
+ lines.push(' // SPACING');
+ lines.push(' // ============================================');
+ for (const [key, value] of Object.entries(tokens.spacing.scale)) {
+ lines.push(` static const double space${capitalize(key)} = ${value}.0;`);
+ }
+
+ // Radii
+ lines.push('');
+ lines.push(' // ============================================');
+ lines.push(' // BORDER RADIUS');
+ lines.push(' // ============================================');
+ for (const [key, value] of Object.entries(tokens.radii)) {
+ lines.push(` static const double radius${capitalize(key)} = ${value}.0;`);
+ }
+ lines.push(` static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);`);
+ lines.push(` static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);`);
+ lines.push(` static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);`);
+
+ // Layout
+ lines.push('');
+ lines.push(' // ============================================');
+ lines.push(' // LAYOUT');
+ lines.push(' // ============================================');
+ lines.push(` static const double maxWidth = ${tokens.layout.maxWidth}.0;`);
+ lines.push(` static const double maxWidthNarrow = ${tokens.layout.maxWidthNarrow}.0;`);
+ lines.push(` static const double maxWidthForm = ${tokens.layout.maxWidthForm}.0;`);
+
+ // Typography helpers
+ lines.push('');
+ lines.push(' // ============================================');
+ lines.push(' // TYPOGRAPHY');
+ lines.push(' // ============================================');
+ lines.push(` static String get fontFamily => '${tokens.typography.fontFamily}';`);
+ lines.push('');
+
+ // Text styles
+ const textStyles = {
+ h1: { size: 36, weight: 300, spacing: -0.5 },
+ h1Large: { size: 40, weight: 700 },
+ h2: { size: 24, weight: 300, spacing: -0.3 },
+ h3: { size: 18, weight: 500 },
+ bodyLarge: { size: 16, weight: 400 },
+ bodyMedium: { size: 15, weight: 400 },
+ bodySmall: { size: 13, weight: 400 },
+ labelLarge: { size: 15, weight: 500 },
+ labelSmall: { size: 12, weight: 500, spacing: 1.5 },
+ };
+
+ for (const [name, style] of Object.entries(textStyles)) {
+ const weight = `FontWeight.w${style.weight}`;
+ const spacing = style.spacing ? `, letterSpacing: ${style.spacing}` : '';
+ lines.push(` static TextStyle get ${name} => GoogleFonts.sora(`);
+ lines.push(` fontSize: ${style.size}.0,`);
+ lines.push(` fontWeight: ${weight},`);
+ lines.push(` color: text${spacing},`);
+ lines.push(` );`);
+ lines.push('');
+ }
+
+ // Color scheme
+ lines.push(' // ============================================');
+ lines.push(' // THEME DATA');
+ lines.push(' // ============================================');
+ lines.push(' static ThemeData get light => ThemeData(');
+ lines.push(' useMaterial3: true,');
+ lines.push(' brightness: Brightness.light,');
+ lines.push(' scaffoldBackgroundColor: bg,');
+ lines.push(' colorScheme: ColorScheme.light(');
+ lines.push(' primary: accent,');
+ lines.push(' onPrimary: Colors.white,');
+ lines.push(' secondary: accentLight,');
+ lines.push(' onSecondary: accent,');
+ lines.push(' surface: bgCard,');
+ lines.push(' onSurface: text,');
+ lines.push(' error: danger,');
+ lines.push(' onError: Colors.white,');
+ lines.push(' outline: border,');
+ lines.push(' ),');
+ lines.push(' textTheme: TextTheme(');
+ lines.push(' displayLarge: h1Large,');
+ lines.push(' displayMedium: h1,');
+ lines.push(' headlineMedium: h2,');
+ lines.push(' headlineSmall: h3,');
+ lines.push(' bodyLarge: bodyLarge,');
+ lines.push(' bodyMedium: bodyMedium,');
+ lines.push(' bodySmall: bodySmall,');
+ lines.push(' labelLarge: labelLarge,');
+ lines.push(' labelSmall: labelSmall,');
+ lines.push(' ),');
+ lines.push(' appBarTheme: AppBarTheme(');
+ lines.push(' backgroundColor: bg,');
+ lines.push(' foregroundColor: text,');
+ lines.push(' elevation: 0,');
+ lines.push(' centerTitle: false,');
+ lines.push(' ),');
+ lines.push(' cardTheme: CardTheme(');
+ lines.push(' color: bgCard,');
+ lines.push(' elevation: 0,');
+ lines.push(' shape: RoundedRectangleBorder(');
+ lines.push(' borderRadius: borderRadiusLg,');
+ lines.push(' side: BorderSide(color: border),');
+ lines.push(' ),');
+ lines.push(' ),');
+ lines.push(' elevatedButtonTheme: ElevatedButtonThemeData(');
+ lines.push(' style: ElevatedButton.styleFrom(');
+ lines.push(' backgroundColor: accent,');
+ lines.push(' foregroundColor: Colors.white,');
+ lines.push(' elevation: 0,');
+ lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),');
+ lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),');
+ lines.push(' textStyle: labelLarge,');
+ lines.push(' ),');
+ lines.push(' ),');
+ lines.push(' outlinedButtonTheme: OutlinedButtonThemeData(');
+ lines.push(' style: OutlinedButton.styleFrom(');
+ lines.push(' foregroundColor: text,');
+ lines.push(' side: BorderSide(color: border),');
+ lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),');
+ lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),');
+ lines.push(' textStyle: labelLarge,');
+ lines.push(' ),');
+ lines.push(' ),');
+ lines.push(' inputDecorationTheme: InputDecorationTheme(');
+ lines.push(' filled: true,');
+ lines.push(' fillColor: bgCard,');
+ lines.push(' border: OutlineInputBorder(');
+ lines.push(' borderRadius: borderRadiusMd,');
+ lines.push(' borderSide: BorderSide(color: border),');
+ lines.push(' ),');
+ lines.push(' enabledBorder: OutlineInputBorder(');
+ lines.push(' borderRadius: borderRadiusMd,');
+ lines.push(' borderSide: BorderSide(color: border),');
+ lines.push(' ),');
+ lines.push(' focusedBorder: OutlineInputBorder(');
+ lines.push(' borderRadius: borderRadiusMd,');
+ lines.push(' borderSide: BorderSide(color: accent, width: 2),');
+ lines.push(' ),');
+ lines.push(' contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd),');
+ lines.push(' ),');
+ lines.push(' dividerTheme: DividerThemeData(');
+ lines.push(' color: border,');
+ lines.push(' thickness: 1,');
+ lines.push(' ),');
+ lines.push(' );');
+
+ lines.push('}');
+
+ return lines.join('\n');
+}
+
+// ============================================
+// UTILITIES
+// ============================================
+function camelToKebab(str) {
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+}
+
+function capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+// ============================================
+// MAIN
+// ============================================
+function main() {
+ // Generate CSS
+ const css = generateCSS(tokens);
+ const cssPath = path.join(__dirname, 'tokens.css');
+ fs.writeFileSync(cssPath, css);
+ console.log(`✓ Generated ${cssPath}`);
+
+ // Generate Flutter
+ const flutter = generateFlutter(tokens);
+ const flutterDir = path.join(__dirname, 'flutter');
+ if (!fs.existsSync(flutterDir)) {
+ fs.mkdirSync(flutterDir, { recursive: true });
+ }
+ const flutterPath = path.join(flutterDir, 'inou_theme.dart');
+ fs.writeFileSync(flutterPath, flutter);
+ console.log(`✓ Generated ${flutterPath}`);
+
+ console.log('\nDesign tokens synced. Both CSS and Flutter use the same values.');
+}
+
+main();
diff --git a/design/tokens.css b/design/tokens.css
new file mode 100644
index 0000000..3e7955d
--- /dev/null
+++ b/design/tokens.css
@@ -0,0 +1,37 @@
+/* AUTO-GENERATED from tokens.json — do not edit directly */
+/* Run: node design/generate.js */
+
+:root {
+ --bg: #F8F7F6;
+ --bg-card: #FFFFFF;
+ --border: #E5E2DE;
+ --border-hover: #C4BFB8;
+ --text: #1C1917;
+ --text-muted: #78716C;
+ --text-subtle: #A8A29E;
+ --accent: #B45309;
+ --accent-hover: #92400E;
+ --accent-light: #FEF3C7;
+ --danger: #DC2626;
+ --danger-light: #FEF2F2;
+ --success: #059669;
+ --success-light: #ECFDF5;
+
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 12px;
+ --space-lg: 16px;
+ --space-xl: 24px;
+ --space-xxl: 32px;
+ --space-xxxl: 48px;
+
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius-full: 9999px;
+
+ --max-width: 1200px;
+ --max-width-narrow: 800px;
+ --max-width-form: 360px;
+}
\ No newline at end of file
diff --git a/design/tokens.json b/design/tokens.json
new file mode 100644
index 0000000..c3e2c96
--- /dev/null
+++ b/design/tokens.json
@@ -0,0 +1,120 @@
+{
+ "$schema": "https://inou.com/design-tokens.schema.json",
+ "name": "inou",
+ "version": "1.0.0",
+
+ "colors": {
+ "bg": "#F8F7F6",
+ "bgCard": "#FFFFFF",
+ "border": "#E5E2DE",
+ "borderHover": "#C4BFB8",
+ "text": "#1C1917",
+ "textMuted": "#78716C",
+ "textSubtle": "#A8A29E",
+ "accent": "#B45309",
+ "accentHover": "#92400E",
+ "accentLight": "#FEF3C7",
+ "danger": "#DC2626",
+ "dangerLight": "#FEF2F2",
+ "success": "#059669",
+ "successLight": "#ECFDF5"
+ },
+
+ "typography": {
+ "fontFamily": "Sora",
+ "fontFamilyMono": "SF Mono, Monaco, Consolas, monospace",
+ "fontFamilyFallback": "-apple-system, BlinkMacSystemFont, sans-serif",
+ "baseFontSize": 15,
+ "lineHeight": 1.5,
+ "weights": {
+ "light": 300,
+ "regular": 400,
+ "medium": 500,
+ "semibold": 600,
+ "bold": 700
+ },
+ "scale": {
+ "h1": { "size": "2.25rem", "weight": 300, "letterSpacing": "-0.03em" },
+ "h1Large": { "size": "2.5rem", "weight": 700 },
+ "h2": { "size": "1.5rem", "weight": 300, "letterSpacing": "-0.02em" },
+ "h3": { "size": "1.125rem", "weight": 500 },
+ "body": { "size": "1rem", "weight": 400 },
+ "small": { "size": "0.85rem", "weight": 400 },
+ "tiny": { "size": "0.75rem", "weight": 500, "letterSpacing": "0.1em", "transform": "uppercase" },
+ "code": { "size": "0.85rem", "family": "mono" }
+ }
+ },
+
+ "spacing": {
+ "unit": 4,
+ "scale": {
+ "xs": 4,
+ "sm": 8,
+ "md": 12,
+ "lg": 16,
+ "xl": 24,
+ "xxl": 32,
+ "xxxl": 48
+ }
+ },
+
+ "radii": {
+ "sm": 4,
+ "md": 6,
+ "lg": 8,
+ "xl": 12,
+ "full": 9999
+ },
+
+ "shadows": {
+ "dropdown": "0 4px 12px rgba(0,0,0,0.1)",
+ "modal": "0 20px 25px -5px rgba(0,0,0,0.15)",
+ "card": "0 0 0 1px var(--accent)"
+ },
+
+ "layout": {
+ "maxWidth": 1200,
+ "maxWidthNarrow": 800,
+ "maxWidthForm": 360,
+ "navPadding": { "y": 12, "x": 24 },
+ "containerPadding": { "y": 48, "x": 24 }
+ },
+
+ "components": {
+ "button": {
+ "padding": { "y": 10, "x": 18 },
+ "paddingSmall": { "y": 6, "x": 12 },
+ "fontSize": "1rem",
+ "fontWeight": 500,
+ "borderRadius": "md"
+ },
+ "card": {
+ "padding": 16,
+ "borderRadius": "lg",
+ "borderWidth": 1
+ },
+ "input": {
+ "padding": { "y": 10, "x": 12 },
+ "fontSize": "1rem",
+ "borderRadius": "md"
+ },
+ "badge": {
+ "padding": { "y": 2, "x": 8 },
+ "fontSize": "1rem",
+ "fontWeight": 500,
+ "borderRadius": "sm"
+ }
+ },
+
+ "indicators": {
+ "imaging": "#B45309",
+ "labs": "#059669",
+ "uploads": "#6366f1",
+ "vitals": "#ec4899",
+ "medications": "#8b5cf6",
+ "records": "#06b6d4",
+ "journal": "#f59e0b",
+ "privacy": "#64748b",
+ "genetics": "#10b981"
+ }
+}
diff --git a/docs/._.DS_Store b/docs/._.DS_Store
new file mode 100644
index 0000000..28c42fb
Binary files /dev/null and b/docs/._.DS_Store differ
diff --git a/docs/soc2/._data-retention-policy.md b/docs/soc2/._data-retention-policy.md
new file mode 100644
index 0000000..8b8912b
Binary files /dev/null and b/docs/soc2/._data-retention-policy.md differ
diff --git a/docs/soc2/._disaster-recovery-plan.md b/docs/soc2/._disaster-recovery-plan.md
new file mode 100644
index 0000000..501595b
Binary files /dev/null and b/docs/soc2/._disaster-recovery-plan.md differ
diff --git a/docs/soc2/._incident-response-plan.md b/docs/soc2/._incident-response-plan.md
new file mode 100644
index 0000000..a54397c
Binary files /dev/null and b/docs/soc2/._incident-response-plan.md differ
diff --git a/docs/soc2/._risk-assessment.md b/docs/soc2/._risk-assessment.md
new file mode 100644
index 0000000..6ae2293
Binary files /dev/null and b/docs/soc2/._risk-assessment.md differ
diff --git a/docs/soc2/._security-policy.md b/docs/soc2/._security-policy.md
new file mode 100644
index 0000000..dad8132
Binary files /dev/null and b/docs/soc2/._security-policy.md differ
diff --git a/docs/soc2/._soc2-self-assessment-2026.md b/docs/soc2/._soc2-self-assessment-2026.md
new file mode 100644
index 0000000..50d9e9a
Binary files /dev/null and b/docs/soc2/._soc2-self-assessment-2026.md differ
diff --git a/docs/soc2/incident-response-plan.md b/docs/soc2/incident-response-plan.md
index e8b5cb4..ab01fa3 100644
--- a/docs/soc2/incident-response-plan.md
+++ b/docs/soc2/incident-response-plan.md
@@ -39,7 +39,8 @@ All inou systems:
| Role | Name | Email | Phone |
|------|------|-------|-------|
-| Incident Commander | Johan Jongsma | security@inou.com | Available on request |
+| Incident Commander | Johan Jongsma | security@inou.com | Signal: +1 727-225-2475 |
+| AI Operations | James ⚡ | Via OpenClaw | 24/7 automated |
### External Contacts
@@ -56,22 +57,33 @@ All inou systems:
| Security incidents | security@inou.com |
| User support | support@inou.com |
+### Alert Flow
+
+```
+Uptime Kuma (Zurich) → Webhook → OpenClaw Gateway → James AI → Signal (Johan)
+Nuclei scan → James AI reviews → Signal alert (if critical/high)
+```
+
---
## 5. Detection
### Automated Detection
+- **Uptime Kuma (Zurich):** 24/7 availability monitoring, 60-second intervals
+- **Nuclei scans:** Weekly + monthly vulnerability scanning from Zurich
- **404 monitoring:** Alerts on suspicious path probes
- **Tarpit triggers:** Logs known attack patterns (PHP probes, config access attempts)
- **Rate limiting:** Flags excessive requests per IP
- **Log analysis:** HTTP access logs reviewed for anomalies
+- **James AI:** Receives alerts, triages, escalates critical issues
### Manual Detection
- User reports of unauthorized access
- Unexpected system behavior
- External notification (security researcher, vendor)
+- James AI analysis of scan results
### Indicators of Compromise
diff --git a/docs/soc2/scans/2026-01/inou-com-20260131.txt b/docs/soc2/scans/2026-01/inou-com-20260131.txt
new file mode 100644
index 0000000..3b86cbb
--- /dev/null
+++ b/docs/soc2/scans/2026-01/inou-com-20260131.txt
@@ -0,0 +1,34 @@
+[missing-sri] [http] [info] https://inou.com ["https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap"]
+[tls-version] [ssl] [info] inou.com:443 ["tls12"]
+[tls-version] [ssl] [info] inou.com:443 ["tls13"]
+[http-missing-security-headers:strict-transport-security] [http] [info] https://inou.com
+[http-missing-security-headers:x-frame-options] [http] [info] https://inou.com
+[http-missing-security-headers:referrer-policy] [http] [info] https://inou.com
+[http-missing-security-headers:clear-site-data] [http] [info] https://inou.com
+[http-missing-security-headers:cross-origin-opener-policy] [http] [info] https://inou.com
+[http-missing-security-headers:content-security-policy] [http] [info] https://inou.com
+[http-missing-security-headers:permissions-policy] [http] [info] https://inou.com
+[http-missing-security-headers:x-content-type-options] [http] [info] https://inou.com
+[http-missing-security-headers:x-permitted-cross-domain-policies] [http] [info] https://inou.com
+[http-missing-security-headers:cross-origin-embedder-policy] [http] [info] https://inou.com
+[http-missing-security-headers:cross-origin-resource-policy] [http] [info] https://inou.com
+[oauth-authorization-server-exposure] [http] [info] https://inou.com/.well-known/oauth-authorization-server
+[rdap-whois:registrationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2001-06-29T10:49:20Z"]
+[rdap-whois:lastChangeDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2025-07-24T06:29:31Z"]
+[rdap-whois:expirationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2026-06-29T10:49:20Z"]
+[rdap-whois:nameServers] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["NS3.OPENPROVIDER.EU","NS1.OPENPROVIDER.NL","NS2.OPENPROVIDER.BE"]
+[rdap-whois:secureDNS] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["false"]
+[rdap-whois:status] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["client transfer prohibited"]
+[tech-detect:caddy] [http] [info] https://inou.com
+[tech-detect:google-font-api] [http] [info] https://inou.com
+[robots-txt] [http] [info] https://inou.com/robots.txt
+[robots-txt-endpoint:endpoints] [http] [info] https://inou.com/robots.txt ["/dashboard","/onboard","/verify","/start","/set-lang","/api/","/connect","/share","/invite","/login","/privacy-policy","/dossier"]
+[spf-record-detect] [dns] [info] inou.com ["v=spf1 include:_spf.protonmail.ch mx ~all""]
+[txt-fingerprint] [dns] [info] inou.com [""v=spf1 include:_spf.protonmail.ch mx ~all"",""protonmail-verification=da8cc10ce04b8fdf2ac85303e3283a537cd30f52"",""google-site-verification=d3PKH4M7jVH88dGfGfsqGM71xsEyvgOspxZPEevGrlc""]
+[nameserver-fingerprint] [dns] [info] inou.com ["ns3.openprovider.eu.","ns2.openprovider.be.","ns1.openprovider.nl."]
+[mx-fingerprint] [dns] [info] inou.com ["20 mailsec.protonmail.ch.","10 mail.protonmail.ch."]
+[mx-service-detector:ProtonMail] [dns] [info] inou.com
+[caa-fingerprint] [dns] [info] inou.com
+[dmarc-detect] [dns] [info] _dmarc.inou.com [""v=DMARC1; p=reject;""]
+[ssl-issuer] [ssl] [info] inou.com:443 ["ZeroSSL"]
+[ssl-dns-names] [ssl] [info] inou.com:443 ["inou.com"]
diff --git a/docs/soc2/scans/2026-01/report.md b/docs/soc2/scans/2026-01/report.md
new file mode 100644
index 0000000..385cae7
--- /dev/null
+++ b/docs/soc2/scans/2026-01/report.md
@@ -0,0 +1,95 @@
+# Vulnerability Scan Report — January 2026
+
+**Scan Date:** January 31, 2026
+**Target:** https://inou.com
+**Scanner:** Nuclei (ProjectDiscovery)
+**Scanner Location:** zurich.inou.com (Zürich, Switzerland)
+
+---
+
+## Executive Summary
+
+| Severity | Count |
+|----------|------:|
+| 🔴 Critical | 0 |
+| 🟠 High | 0 |
+| 🟡 Medium | 0 |
+| 🔵 Low | 0 |
+| ⚪ Informational | 34 |
+
+**Result:** No exploitable vulnerabilities detected. All findings are informational.
+
+---
+
+## Findings & Remediation
+
+### HTTP Security Headers (11 findings)
+
+| Header | Status | Date |
+|--------|:------:|------|
+| Strict-Transport-Security | ✅ Remediated | Feb 1, 2026 |
+| X-Content-Type-Options | ✅ Remediated | Feb 1, 2026 |
+| X-Frame-Options | ✅ Remediated | Feb 1, 2026 |
+| Referrer-Policy | ✅ Remediated | Feb 1, 2026 |
+| Permissions-Policy | ✅ Remediated | Feb 1, 2026 |
+| Cross-Origin-Opener-Policy | ✅ Remediated | Feb 1, 2026 |
+| Cross-Origin-Resource-Policy | ✅ Remediated | Feb 1, 2026 |
+| X-Permitted-Cross-Domain-Policies | ✅ Remediated | Feb 1, 2026 |
+| Content-Security-Policy | ⏸️ Deferred | Requires app tuning |
+| Cross-Origin-Embedder-Policy | ⏸️ Skipped | Breaks Google Fonts |
+| Clear-Site-Data | ⏸️ N/A | Logout only |
+
+**Remediation:** Added headers to Caddy reverse proxy (192.168.0.2).
+
+### TLS/SSL (3 findings)
+
+| Finding | Status |
+|---------|:------:|
+| TLS 1.2 supported | ✅ Expected |
+| TLS 1.3 supported | ✅ Expected |
+| ZeroSSL certificate | ✅ Expected |
+
+### DNS Configuration (10 findings)
+
+| Finding | Status |
+|---------|:------:|
+| SPF configured | ✅ Good |
+| DMARC (p=reject) | ✅ Good |
+| ProtonMail MX | ✅ Expected |
+| DNSSEC not enabled | ⏸️ Low priority |
+
+### Other Informational (10 findings)
+
+- Technology detection (Caddy, Google Fonts) — expected
+- robots.txt endpoints — expected
+- OAuth discovery endpoint — expected
+- Domain WHOIS metadata — informational
+
+---
+
+## Actions Taken
+
+| Date | Action |
+|------|--------|
+| Jan 31, 2026 | Initial baseline scan from Zurich |
+| Feb 1, 2026 | Added 8 HTTP security headers to Caddy |
+| Feb 1, 2026 | Verified headers via curl |
+| Feb 1, 2026 | Set up automated weekly/monthly scans |
+
+---
+
+## Next Steps
+
+1. **P2:** Implement Content-Security-Policy (requires app testing)
+2. **P3:** Enable DNSSEC via Openprovider
+3. **Continue:** Weekly and monthly automated scans
+
+---
+
+## Raw Output
+
+See: [inou-com-20260131.txt](inou-com-20260131.txt)
+
+---
+
+*Report generated by James ⚡ (AI Operations)*
diff --git a/docs/soc2/security-policy.md b/docs/soc2/security-policy.md
index 3e7d06a..fb1d0c1 100644
--- a/docs/soc2/security-policy.md
+++ b/docs/soc2/security-policy.md
@@ -265,6 +265,40 @@ See: [Disaster Recovery Plan](disaster-recovery-plan.md)
| Suspicious 404s | System notification |
| Tarpit triggers | Logged |
| Failed logins | Fail2ban action |
+| Service outage | Uptime Kuma → James AI → Signal |
+| Critical vulnerability | Nuclei → James AI → Signal |
+
+### External Monitoring (Zurich)
+
+| Service | Location | Purpose |
+|---------|----------|---------|
+| Uptime Kuma | zurich.inou.com:3001 | 24/7 availability monitoring |
+| Nuclei | zurich.inou.com | Vulnerability scanning |
+
+---
+
+## 13a. Vulnerability Management
+
+### Scanning Program
+
+| Schedule | Type | Tool | Action |
+|----------|------|------|--------|
+| Monthly (1st, 9am ET) | Full scan | Nuclei | Report + remediate |
+| Weekly (Sun, 10am ET) | Critical/High/Medium | Nuclei | Alert if found |
+| Pre-release | Full scan | Nuclei | Gate deployment |
+
+### Remediation SLAs
+
+| Severity | Response | Resolution |
+|----------|----------|------------|
+| Critical | 4 hours | 24 hours |
+| High | 24 hours | 7 days |
+| Medium | 7 days | 30 days |
+| Low | 30 days | 90 days |
+
+### Scan Results
+
+Results stored in: `docs/soc2/scans/YYYY-MM/`
---
diff --git a/docs/soc2/soc2-self-assessment-2026.md b/docs/soc2/soc2-self-assessment-2026.md
index 0726916..fac7087 100644
--- a/docs/soc2/soc2-self-assessment-2026.md
+++ b/docs/soc2/soc2-self-assessment-2026.md
@@ -1,10 +1,10 @@
# SOC 2 Type II Self-Assessment Report
**Organization:** inou
-**Report Period:** January 1, 2026 to January 25, 2026
-**Assessment Date:** January 25, 2026
+**Report Period:** January 1, 2026 - Ongoing
+**Assessment Date:** January 25, 2026 (Updated February 1, 2026)
**Prepared By:** Johan Jongsma, Founder & Owner
-**Report Version:** 1.0
+**Report Version:** 1.1
---
@@ -57,9 +57,18 @@ inou is a medical imaging platform with AI-powered health data exploration. This
| Control | Status | Evidence |
|---------|--------|----------|
-| CC4.1 Ongoing monitoring | Implemented | HTTP logs, 404 alerts, rate limiting |
+| CC4.1 Ongoing monitoring | Implemented | HTTP logs, 404 alerts, rate limiting, Uptime Kuma (Zurich), Nuclei scans |
| CC4.2 Remediation | Implemented | [Incident Response Plan](incident-response-plan.md) |
+#### External Monitoring (Added February 2026)
+
+| Tool | Location | Purpose | Frequency |
+|------|----------|---------|-----------|
+| Uptime Kuma | zurich.inou.com:3001 | Availability monitoring | Continuous (60s) |
+| Nuclei | zurich.inou.com | Vulnerability scanning | Weekly + Monthly |
+
+**Why Zurich?** External monitoring from Switzerland provides geographic independence and simulates external attacker perspective for vulnerability assessment.
+
### CC5: Control Activities
| Control | Status | Evidence |
@@ -85,11 +94,32 @@ inou is a medical imaging platform with AI-powered health data exploration. This
| Control | Status | Evidence |
|---------|--------|----------|
-| CC7.1 Anomaly detection | Implemented | Tarpit, 404 monitoring, rate limiting |
-| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications |
+| CC7.1 Anomaly detection | Implemented | Tarpit, 404 monitoring, rate limiting, Uptime Kuma alerts |
+| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications, Uptime Kuma webhook → James AI |
| CC7.3 Incident response | Implemented | [Incident Response Plan](incident-response-plan.md) |
| CC7.4 Recovery | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) |
+#### Vulnerability Scanning Program (Added February 2026)
+
+| Schedule | Scan Type | Tool | Targets |
+|----------|-----------|------|---------|
+| Monthly (1st) | Full vulnerability scan | Nuclei | inou.com |
+| Weekly (Sunday) | Critical/High/Medium | Nuclei | inou.com |
+| Ad-hoc | Pre-release | Nuclei | inou.com, dev.inou.com |
+
+**Baseline Scan (January 31, 2026):**
+- 34 findings, all informational
+- No critical, high, or medium vulnerabilities
+- 11 missing HTTP security headers → 8 remediated (February 1, 2026)
+
+#### AI Operations Assistant (Added February 2026)
+
+James (AI assistant via OpenClaw) provides 24/7 operational support:
+- Receives Uptime Kuma alerts via webhook
+- Runs and reviews vulnerability scans
+- Applies security remediations
+- Escalates to owner via Signal for critical issues
+
### CC8: Change Management
| Control | Status | Evidence |
@@ -284,7 +314,20 @@ Failed or decommissioned storage media is physically destroyed, rendering data u
| Automatic updates | Enabled |
| Firewall | UFW active, default deny incoming |
| SSH | Password auth disabled, rate limited |
-| TLS | Automatic HTTPS via Let's Encrypt, TLS 1.2+ |
+| TLS | Automatic HTTPS via ZeroSSL, TLS 1.2+ |
+
+#### HTTP Security Headers (Added February 1, 2026)
+
+| Header | Value |
+|--------|-------|
+| Strict-Transport-Security | `max-age=31536000; includeSubDomains; preload` |
+| X-Content-Type-Options | `nosniff` |
+| X-Frame-Options | `SAMEORIGIN` |
+| Referrer-Policy | `strict-origin-when-cross-origin` |
+| Permissions-Policy | `geolocation=(), microphone=(), camera=()` |
+| Cross-Origin-Opener-Policy | `same-origin-allow-popups` |
+| Cross-Origin-Resource-Policy | `same-origin` |
+| X-Permitted-Cross-Domain-Policies | `none` |
### Network Security (UDM-Pro)
@@ -350,7 +393,7 @@ Failed or decommissioned storage media is physically destroyed, rendering data u
## 10. Action Items
-### Completed This Assessment
+### Completed This Assessment (January 2026)
| Item | Status |
|------|--------|
@@ -363,6 +406,17 @@ Failed or decommissioned storage media is physically destroyed, rendering data u
| Vendor assessment | Documented |
| OS hardening documentation | Documented |
+### Completed (February 2026)
+
+| Item | Status | Date |
+|------|--------|------|
+| External vulnerability scanning | Nuclei from Zurich, automated | Feb 1, 2026 |
+| HTTP security headers | 8 headers added to Caddy | Feb 1, 2026 |
+| External availability monitoring | Uptime Kuma from Zurich | Feb 1, 2026 |
+| Automated alerting | Webhook → James AI → Signal | Feb 1, 2026 |
+| Weekly vulnerability scan | Cron job (Sundays 10am ET) | Feb 1, 2026 |
+| Monthly vulnerability scan | Cron job (1st, 9am ET) | Feb 1, 2026 |
+
### Recommended Actions
| Item | Priority | Target Date |
@@ -370,7 +424,8 @@ Failed or decommissioned storage media is physically destroyed, rendering data u
| Perform backup restore test | P1 | Q1 2026 |
| Complete audit logging in `lib/v2.go` | P2 | Q1 2026 |
| Implement key rotation procedure | P2 | Q2 2026 |
-| Schedule penetration test | P2 | Q2 2026 |
+| Add Content-Security-Policy header | P2 | Q1 2026 |
+| Enable DNSSEC on inou.com | P3 | Q2 2026 |
| Evaluate cyber liability insurance | P3 | Q2 2026 |
---
@@ -424,6 +479,14 @@ Failed or decommissioned storage media is physically destroyed, rendering data u
| Access review | Quarterly | January 2026 | April 2026 |
| Penetration test | Annually | Not yet | Q2 2026 |
+### Automated Security Testing
+
+| Test | Frequency | Last Run | Next Run |
+|------|-----------|----------|----------|
+| Nuclei full scan | Monthly (1st) | Jan 31, 2026 | Feb 1, 2026 |
+| Nuclei light scan | Weekly (Sun) | Feb 1, 2026 | Feb 2, 2026 |
+| Uptime monitoring | Continuous | Live | Live |
+
---
## 13. Conclusion
diff --git a/inou.db b/inou.db
new file mode 100644
index 0000000..788fb97
Binary files /dev/null and b/inou.db differ
diff --git a/inou.mcpb b/inou.mcpb
new file mode 100644
index 0000000..8a1397e
Binary files /dev/null and b/inou.mcpb differ
diff --git a/lang/da.yaml b/lang/da.yaml
new file mode 100644
index 0000000..aa22b64
--- /dev/null
+++ b/lang/da.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Dine sundhedsdata."
+headline_2: "Din AI."
+headline_3: "Dine svar."
+intro: "Upload billeddiagnostik, laboratorieresultater og mere. Forbind din AI for at hjælpe dig med at forstå, hvad du ser på."
+email: "E-mail"
+get_started: "Kom i gang"
+data_yours: "Dine data forbliver dine"
+never_training: "Bruges aldrig til træning"
+never_training_desc: "Dine billeder bruges aldrig til at træne AI-modeller."
+never_shared: "Deles aldrig"
+never_shared_desc: "Vi deler aldrig dine data med nogen."
+encrypted: "Krypteret lagring"
+encrypted_desc: "Alle data krypteret i hvile."
+delete: "Slet når som helst"
+delete_desc: "Dine data, din kontrol."
+
+# Verify
+check_email: "Tjek din e-mail"
+code_sent_to: "Vi har sendt en 6-cifret kode til"
+verification_code: "Bekræftelseskode"
+verify: "Bekræft"
+use_different_email: "Brug en anden e-mail"
+invalid_code: "Ugyldig eller udløbet kode. Prøv igen."
+
+# Onboard
+create_dossier: "Opret din dosje"
+create_profile_intro: "Fortæl os om dig selv for at komme i gang."
+name: "Navn"
+name_placeholder: "Dit navn"
+date_of_birth: "Fødselsdato"
+sex_at_birth: "Køn ved fødslen"
+female: "Kvinde"
+male: "Mand"
+create_my_dossier: "Opret min dosje"
+
+# Minor error
+must_be_18: "Du skal være 18 for at oprette en konto"
+minor_explanation: "Hvis du opretter dette for en anden, start med din egen profil først. Dette sikrer, at kun du kan få adgang til deres sundhedsdata."
+minor_next_steps: "Efter at have oprettet din dosje kan du tilføje andre."
+use_different_dob: "Brug en anden fødselsdato"
+
+# Minor login block
+minor_login_blocked: "Du skal være 18 for at logge ind"
+minor_ask_guardian: "Bed %s om adgang til din dosje."
+minor_ask_guardian_generic: "Bed en forælder eller værge om adgang til din dosje."
+
+# Dashboard
+dossiers: "Dosjer"
+dossiers_intro: "Administrer sundhedsdata for dig selv eller andre"
+you: "dig"
+view: "Vis"
+save: "Gem"
+cancel: "Annuller"
+add_dossier: "Tilføj dosje"
+edit_dossier: "Rediger dosje"
+care: "pleje"
+logout: "Log ud"
+
+# Profile detail
+back_to_dossiers: "Tilbage til dosjer"
+born: "Født"
+no_access_yet: "Kun du har adgang."
+people_with_access: "Personer med adgang"
+share_access: "Del adgang"
+can_edit: "kan tilføje data"
+remove: "Fjern"
+confirm_revoke: "Fjern adgang?"
+
+# Dossier sections
+section_imaging: "Billeddiagnostik"
+section_labs: "Lab"
+section_uploads: "Uploads"
+section_vitals: "Vitale tegn"
+section_medications: "Medicin"
+section_records: "Journaler"
+section_journal: "Dagbog"
+section_genetics: "Genetik"
+section_privacy: "Privatliv"
+
+# Section summaries
+imaging_summary: "%d undersøgelser · %d snit"
+no_imaging: "Ingen billeddata"
+no_lab_data: "Ingen labdata"
+no_genetics: "Ingen genetiske data"
+no_files: "Ingen filer"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d filer (%s)"
+series_count: "%d serier"
+vitals_desc: "Blodtryk, puls, SpO₂, vægt, blodsukker"
+medications_desc: "Recepter og kosttilskud"
+records_desc: "Kliniske noter og journaler"
+journal_desc: "Symptomer, smerte og observationer"
+
+# Buttons and actions
+open_viewer: "Åbn visning"
+manage: "Administrer"
+show_all_studies: "Vis alle %d undersøgelser..."
+coming_soon: "Kommer snart"
+
+# Upload page
+upload_files: "Upload sundhedsdata"
+upload_files_intro: "Upload medicinsk billeddiagnostik, laboratorieresultater, genomfiler eller sundhedsrelaterede dokumenter."
+upload_hint_broad: "DICOM, PDF, CSV, VCF og mere"
+uploading: "Uploader..."
+files_uploaded: "filer uploadet"
+upload_scans: "Upload scanninger"
+upload_scans_intro: "Upload en mappe med DICOM-filer fra din billedundersøgelse."
+upload_drop: "Klik eller træk en mappe hertil"
+upload_hint: "Kun DICOM-mapper"
+
+# Add profile
+add_dossier_intro: "Tilføj nogen, hvis sundhedsdata du vil administrere."
+email_optional: "E-mail (valgfrit)"
+email_optional_hint: "Hvis de er 18, kan de logge ind selv"
+your_relation: "Dit forhold til dem"
+select_relation: "Vælg..."
+i_provide_care: "Jeg yder pleje til denne person"
+i_am_their: "Jeg er deres..."
+
+# Share access
+share_access_intro: "Inviter nogen til at få adgang"
+their_relation: "Deres forhold til denne person"
+can_add_data: "Kan tilføje data (kosttilskud, noter, osv.)"
+send_invitation: "Send invitation"
+back_to_dossier: "Tilbage til dosje"
+
+# Relations
+my_role: "min rolle"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s tilføjede dig til inou"
+invite_email_body: "%s tilføjede din sundhedsdosje til inou, så du kan se og administrere dine medicinske data."
+invite_email_cta: "Log ind for at se"
+continue: "Fortsæt"
+
+# Access management
+people_with_access_count: "personer med adgang"
+view_audit_log: "Vis aktivitetslog"
+export_data: "Download my data"
+relation_with: "Forhold til"
+audit_log: "Aktivitetslog"
+audit_log_intro: "Aktivitetshistorik for"
+audit_log_desc: "Spor hvem der har haft adgang til eller ændret denne dosje"
+
+# Install / Connect
+install_title: "Forbind til Claude"
+install_intro: "Opsæt inou-broen for at lade Claude analysere dine sundhedsdata"
+install_step1: "Trin 1: Download"
+install_step1_desc: "Hent broen til din platform"
+install_download_intro: "Download inou-broen til dit operativsystem:"
+install_step2: "Trin 2: Konfigurer"
+install_step2_desc: "Tilføj til Claude Desktop-konfigurationen"
+install_config_intro: "Tilføj dette til din Claude Desktop-konfigurationsfil:"
+install_step3: "Trin 3: Test"
+install_step3_desc: "Bekræft forbindelsen"
+install_test_intro: "Genstart Claude Desktop og spørg: 'Vis mig mine inou-profiler'"
+nav_install: "Forbind til Claude"
+nav_home: "Hjem"
+
+# Status
+pending: "afventer"
+rate_limit_exceeded: "For mange tilmeldingsforsøg fra din placering. Prøv igen i morgen."
+
+# Sex display
+sex_male: "mand"
+sex_female: "kvinde"
+sex_na: "andet"
+
+# Friend invite email
+friend_invite_subject: "Tjek dette ud — %s"
+friend_invite_p1: "Jeg bruger inou, den sikre måde at opbevare sundhedsdata og udforske dem med AI. Det holder al min families sundhedsinformation ét sted — billedstudier, laboratorieresultater, journaler — og jeg tænkte, det måske også kunne være nyttigt for dig."
+friend_invite_p2: "Den virkelige styrke ligger i at kunne bruge AI til at forstå det hele: forstå hvad en rapport faktisk betyder, opdage tendenser over tid, eller bare stille spørgsmål på almindeligt dansk og få klare svar."
+friend_invite_btn: "Opdag inou"
+friend_invite_dear: "Hej %s,"
+rel_0: "du"
+rel_1: "Forælder"
+rel_2: "Barn"
+rel_3: "Ægtefælle"
+rel_4: "Søskende"
+rel_5: "Værge"
+rel_6: "Omsorgsgiver"
+rel_7: "Coach"
+rel_8: "Læge"
+rel_9: "Ven"
+rel_10: "Andet"
+rel_99: "Demo"
+select_relation: "Vælg relation..."
+
+# Kategorier
+category000: Billeddiagnostik
+category001: Dokument
+category002: Laboratorieresultat
+category003: Genom
+category004: Upload
+category005: Konsultation
+category006: Diagnose
+category007: Billedresultat
+category008: EEG-resultat
+category009: Vitalværdi
+category010: Motion
+category011: Medicin
+category012: Tilskud
+category013: Ernæring
+category014: Fertilitet
+category015: Symptom
+category016: Note
+category017: Sygehistorie
+category018: Familiehistorie
+category019: Kirurgi
+category020: Hospitalsindlæggelse
+category021: Fødselsdata
+category022: Medicinsk udstyr
+category023: Terapi
+category024: Vurdering
+category025: Sundhedsudbyder
+category026: Spørgsmål
+
+# Genome
+genome_english_only: "Al genetisk information er på engelsk. Brug Claude til at diskutere det på dansk."
+genome_variants: "varianter"
+genome_hidden: "skjulte"
+genome_show_all_categories: "Vis alle %d kategorier"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/de.yaml b/lang/de.yaml
new file mode 100644
index 0000000..aa9a447
--- /dev/null
+++ b/lang/de.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Ihre Gesundheitsdaten."
+headline_2: "Ihre KI."
+headline_3: "Ihre Antworten."
+intro: "Laden Sie Bildgebung, Laborergebnisse und mehr hoch. Verbinden Sie Ihre KI, um zu verstehen, was Sie sehen."
+email: "E-Mail"
+get_started: "Loslegen"
+data_yours: "Ihre Daten bleiben Ihre"
+never_training: "Nie für Training verwendet"
+never_training_desc: "Ihre Bilder werden nie zum Trainieren von KI-Modellen verwendet."
+never_shared: "Nie geteilt"
+never_shared_desc: "Wir teilen Ihre Daten mit niemandem."
+encrypted: "Verschlüsselte Speicherung"
+encrypted_desc: "Alle Daten werden verschlüsselt gespeichert."
+delete: "Jederzeit löschen"
+delete_desc: "Ihre Daten, Ihre Kontrolle."
+
+# Verify
+check_email: "Überprüfen Sie Ihre E-Mail"
+code_sent_to: "Wir haben einen 6-stelligen Code gesendet an"
+verification_code: "Bestätigungscode"
+verify: "Bestätigen"
+use_different_email: "Andere E-Mail verwenden"
+invalid_code: "Ungültiger oder abgelaufener Code. Bitte versuchen Sie es erneut."
+
+# Onboard
+create_dossier: "Erstellen Sie Ihr Dossier"
+create_profile_intro: "Erzählen Sie uns von sich, um loszulegen."
+name: "Name"
+name_placeholder: "Ihr Name"
+date_of_birth: "Geburtsdatum"
+sex_at_birth: "Geschlecht bei Geburt"
+female: "Weiblich"
+male: "Männlich"
+create_my_dossier: "Mein Dossier erstellen"
+
+# Minor error
+must_be_18: "Sie müssen 18 sein, um ein Konto zu erstellen"
+minor_explanation: "Wenn Sie dies für jemand anderen einrichten, beginnen Sie zuerst mit Ihrem eigenen Profil. So stellen Sie sicher, dass nur Sie auf deren Gesundheitsdaten zugreifen können."
+minor_next_steps: "Nach der Erstellung Ihres Dossiers können Sie weitere hinzufügen."
+use_different_dob: "Anderes Geburtsdatum verwenden"
+
+# Minor login block
+minor_login_blocked: "Sie müssen 18 sein, um sich anzumelden"
+minor_ask_guardian: "Bitten Sie %s, auf Ihr Dossier zuzugreifen."
+minor_ask_guardian_generic: "Bitten Sie einen Elternteil oder Vormund, auf Ihr Dossier zuzugreifen."
+
+# Dashboard
+dossiers: "Dossiers"
+dossiers_intro: "Verwalten Sie Gesundheitsdaten für sich selbst oder andere"
+you: "Sie"
+view: "Ansehen"
+save: "Speichern"
+cancel: "Abbrechen"
+add_dossier: "Dossier hinzufügen"
+edit_dossier: "Dossier bearbeiten"
+care: "Pflege"
+logout: "Abmelden"
+
+# Profile detail
+back_to_dossiers: "Zurück zu Dossiers"
+born: "Geboren"
+no_access_yet: "Nur Sie haben Zugriff."
+people_with_access: "Personen mit Zugriff"
+share_access: "Zugriff teilen"
+can_edit: "kann Daten hinzufügen"
+remove: "Entfernen"
+confirm_revoke: "Zugriff entfernen?"
+
+# Dossier sections
+section_imaging: "Bildgebung"
+section_labs: "Labor"
+section_uploads: "Uploads"
+section_vitals: "Vitalwerte"
+section_medications: "Medikamente"
+section_records: "Unterlagen"
+section_journal: "Tagebuch"
+section_genetics: "Genetik"
+section_privacy: "Datenschutz"
+
+# Section summaries
+imaging_summary: "%d Studien · %d Schichten"
+no_imaging: "Keine Bildgebungsdaten"
+no_lab_data: "Keine Labordaten"
+no_genetics: "Keine genetischen Daten"
+no_files: "Keine Dateien"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d Dateien (%s)"
+series_count: "%d Serien"
+vitals_desc: "Blutdruck, Herzfrequenz, SpO₂, Gewicht, Glukose"
+medications_desc: "Rezepte und Nahrungsergänzungsmittel"
+records_desc: "Klinische Notizen und Krankenakten"
+journal_desc: "Symptome, Schmerzen und Beobachtungen"
+
+# Buttons and actions
+open_viewer: "Viewer öffnen"
+manage: "Verwalten"
+show_all_studies: "Alle %d Studien anzeigen..."
+coming_soon: "Demnächst"
+
+# Upload page
+upload_files: "Gesundheitsdaten hochladen"
+upload_files_intro: "Laden Sie medizinische Bildgebung, Laborergebnisse, Genomdateien oder andere gesundheitsbezogene Dokumente hoch."
+upload_hint_broad: "DICOM, PDF, CSV, VCF und mehr"
+uploading: "Wird hochgeladen..."
+files_uploaded: "Dateien hochgeladen"
+upload_scans: "Scans hochladen"
+upload_scans_intro: "Laden Sie einen Ordner mit DICOM-Dateien aus Ihrer Bildgebungsstudie hoch."
+upload_drop: "Klicken oder Ordner hierher ziehen"
+upload_hint: "Nur DICOM-Ordner"
+
+# Add profile
+add_dossier_intro: "Fügen Sie jemanden hinzu, dessen Gesundheitsdaten Sie verwalten möchten."
+email_optional: "E-Mail (optional)"
+email_optional_hint: "Wenn sie 18 sind, können sie sich selbst anmelden"
+your_relation: "Ihre Beziehung zu dieser Person"
+select_relation: "Auswählen..."
+i_provide_care: "Ich pflege diese Person"
+i_am_their: "Ich bin deren..."
+
+# Share access
+share_access_intro: "Jemanden zum Zugriff einladen"
+their_relation: "Deren Beziehung zu dieser Person"
+can_add_data: "Kann Daten hinzufügen (Nahrungsergänzungsmittel, Notizen, usw.)"
+send_invitation: "Einladung senden"
+back_to_dossier: "Zurück zum Dossier"
+
+# Relations
+my_role: "meine Rolle"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s hat Sie zu inou hinzugefügt"
+invite_email_body: "%s hat Ihr Gesundheitsdossier zu inou hinzugefügt, damit Sie Ihre medizinischen Daten einsehen und verwalten können."
+invite_email_cta: "Anmelden zum Ansehen"
+continue: "Weiter"
+
+# Access management
+people_with_access_count: "Personen mit Zugriff"
+view_audit_log: "Aktivitätsprotokoll ansehen"
+export_data: "Download my data"
+relation_with: "Beziehung zu"
+audit_log: "Aktivitätsprotokoll"
+audit_log_intro: "Aktivitätsverlauf für"
+audit_log_desc: "Verfolgen Sie, wer auf dieses Dossier zugegriffen oder es geändert hat"
+
+# Install / Connect
+install_title: "Mit Claude verbinden"
+install_intro: "Richten Sie die inou-Bridge ein, damit Claude Ihre Gesundheitsdaten analysieren kann"
+install_step1: "Schritt 1: Herunterladen"
+install_step1_desc: "Laden Sie die Bridge für Ihre Plattform herunter"
+install_download_intro: "Laden Sie die inou-Bridge für Ihr Betriebssystem herunter:"
+install_step2: "Schritt 2: Konfigurieren"
+install_step2_desc: "Zur Claude Desktop-Konfiguration hinzufügen"
+install_config_intro: "Fügen Sie dies zu Ihrer Claude Desktop-Konfigurationsdatei hinzu:"
+install_step3: "Schritt 3: Testen"
+install_step3_desc: "Verbindung überprüfen"
+install_test_intro: "Starten Sie Claude Desktop neu und fragen Sie: 'Zeige mir meine inou-Profile'"
+nav_install: "Mit Claude verbinden"
+nav_home: "Startseite"
+
+# Status
+pending: "ausstehend"
+rate_limit_exceeded: "Zu viele Anmeldeversuche von Ihrem Standort. Bitte versuchen Sie es morgen erneut."
+
+# Sex display
+sex_male: "männlich"
+sex_female: "weiblich"
+sex_na: "andere"
+
+# Friend invite email
+friend_invite_subject: "Schau dir das an — %s"
+friend_invite_p1: "Ich nutze inou, die sichere Art, Gesundheitsdaten zu speichern und mit KI zu erkunden. Es hält alle Gesundheitsinformationen meiner Familie an einem Ort — Bildgebung, Laborergebnisse, Krankenakten — und ich dachte, es könnte auch für dich nützlich sein."
+friend_invite_p2: "Die wahre Stärke liegt darin, KI nutzen zu können, um alles zu verstehen: zu verstehen, was ein Bericht wirklich bedeutet, Trends über die Zeit zu erkennen, oder einfach Fragen in normaler Sprache zu stellen und klare Antworten zu bekommen."
+friend_invite_btn: "Entdecke inou"
+friend_invite_dear: "Liebe/r %s,"
+rel_0: "du"
+rel_1: "Elternteil"
+rel_2: "Kind"
+rel_3: "Ehepartner"
+rel_4: "Geschwister"
+rel_5: "Vormund"
+rel_6: "Betreuer"
+rel_7: "Coach"
+rel_8: "Arzt"
+rel_9: "Freund"
+rel_10: "Andere"
+rel_99: "Demo"
+select_relation: "Beziehung auswählen..."
+
+# Kategorien
+category000: Bildgebung
+category001: Dokument
+category002: Laborergebnis
+category003: Genom
+category004: Upload
+category005: Konsultation
+category006: Diagnose
+category007: Bildgebungsergebnis
+category008: EEG-Ergebnis
+category009: Vitalwert
+category010: Bewegung
+category011: Medikament
+category012: Nahrungsergänzung
+category013: Ernährung
+category014: Fruchtbarkeit
+category015: Symptom
+category016: Notiz
+category017: Krankengeschichte
+category018: Familienanamnese
+category019: Operation
+category020: Krankenhausaufenthalt
+category021: Geburtsdaten
+category022: Medizinisches Gerät
+category023: Therapie
+category024: Bewertung
+category025: Gesundheitsdienstleister
+category026: Frage
+
+# Genome
+genome_english_only: "Alle genetischen Informationen sind auf Englisch. Verwenden Sie Claude, um sie auf Deutsch zu besprechen."
+genome_variants: "Varianten"
+genome_hidden: "verborgen"
+genome_show_all_categories: "Alle %d Kategorien anzeigen"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/en.yaml b/lang/en.yaml
new file mode 100644
index 0000000..9d5e38a
--- /dev/null
+++ b/lang/en.yaml
@@ -0,0 +1,269 @@
+# Landing
+headline_1: "Your health data."
+headline_2: "Your AI."
+headline_3: "Your answers."
+intro: "Upload imaging, labs, and more. Connect your AI to help you understand what you're looking at."
+email: "Email"
+get_started: "Get started"
+data_yours: "Your data stays yours"
+never_training: "Never used for training"
+never_training_desc: "Your images are never used to train AI models."
+never_shared: "Never shared"
+never_shared_desc: "We never share your data with anyone."
+encrypted: "Military-grade encryption"
+encrypted_desc: "At rest and in transit. Your data never travels unprotected."
+delete: "Delete anytime"
+delete_desc: "Your data, your control."
+
+# Verify
+check_email: "Check your email"
+code_sent_to: "We sent a 6-digit code to"
+verification_code: "Verification code"
+verify: "Verify"
+use_different_email: "Use a different email"
+invalid_code: "Invalid or expired code. Please try again."
+
+# Onboard
+create_dossier: "Create your dossier"
+create_profile_intro: "Tell us about yourself to get started."
+name: "Name"
+name_placeholder: "Your name"
+date_of_birth: "Date of birth"
+sex_at_birth: "Sex at birth"
+female: "Female"
+male: "Male"
+create_my_dossier: "Create my dossier"
+
+# Minor error
+must_be_18: "You must be 18 to create an account"
+minor_explanation: "If you're setting this up for someone else, start with your own profile first. This ensures only you can access their health data."
+minor_next_steps: "After creating your dossier, you can add others."
+use_different_dob: "Use a different date of birth"
+
+# Minor login block
+minor_login_blocked: "You must be 18 to log in"
+minor_ask_guardian: "Ask %s to access your dossier."
+minor_ask_guardian_generic: "Ask a parent or guardian to access your dossier."
+
+# Dashboard
+dossiers: "Dossiers"
+dossiers_intro: "Manage health data for yourself or others"
+you: "you"
+view: "View"
+save: "Save"
+cancel: "Cancel"
+add_dossier: "Add dossier"
+edit_dossier: "Edit dossier"
+care: "care"
+logout: "Sign out"
+
+# Profile detail
+back_to_dossiers: "Back to dossiers"
+born: "Born"
+no_access_yet: "Only you have access."
+people_with_access: "People with access"
+share_access: "Share access"
+manage_permissions: "Manage permissions"
+can_edit: "can add data"
+remove: "Remove"
+confirm_revoke: "Remove access?"
+
+# Dossier sections
+section_imaging: "Imaging"
+section_labs: "Labs"
+section_uploads: "Uploads"
+section_vitals: "Vitals"
+section_medications: "Medications"
+section_records: "Records"
+section_journal: "Journal"
+
+# Section summaries
+imaging_summary: "%d studies · %d slices"
+no_imaging: "No imaging data"
+no_lab_data: "No lab data"
+no_files: "No files"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d files (%s)"
+series_count: "%d series"
+vitals_desc: "Blood pressure, heart rate, SpO₂, weight, glucose"
+medications_desc: "Prescriptions and supplements"
+records_desc: "Clinical notes and medical records"
+journal_desc: "Symptoms, pain, and observations"
+
+# Buttons and actions
+open_viewer: "Open viewer"
+manage: "Manage"
+show_all_studies: "Show all %d studies..."
+coming_soon: "Coming soon"
+
+# Upload page
+upload_files: "Upload health data"
+upload_files_intro: "Upload medical imaging, lab results, genome files, or any health-related documents."
+upload_hint_broad: "DICOM, PDF, CSV, VCF, and more"
+uploading: "Uploading..."
+files_uploaded: "files uploaded"
+upload_scans: "Upload scans"
+upload_scans_intro: "Upload a folder containing DICOM files from your imaging study."
+upload_drop: "Click or drag a folder here"
+upload_hint: "DICOM folders only"
+
+# Add profile
+add_dossier_intro: "Add someone whose health data you want to manage."
+email_optional: "Email (optional)"
+email_optional_hint: "If they're 18+, they can log in themselves"
+your_relation: "Your relationship to them"
+select_relation: "Select..."
+i_provide_care: "I provide care for this person"
+
+# Share access
+share_access_intro: "Invite someone to access"
+their_relation: "Their relationship to this person"
+can_add_data: "Can add data (supplements, notes, etc.)"
+send_invitation: "Send invitation"
+back_to_dossier: "Back to dossier"
+
+# Relations
+
+# Invitation email
+invite_email_subject: "%s added you to inou"
+invite_email_body: "%s added your health dossier to inou so you can view and manage your medical data."
+invite_email_cta: "Sign in to view"
+continue: "Continue"
+i_am_their: "I am their..."
+
+# Simple relation names (for display)
+my_role: "my role"
+role: "role"
+section_privacy: "Privacy"
+people_with_access_count: "people with access"
+view_audit_log: "View audit log"
+export_data: "Download my data"
+relation_with: "Relation with"
+audit_log: "Audit log"
+audit_log_intro: "Activity history for"
+audit_log_desc: "Track who accessed or modified this dossier"
+
+# Permissions (RBAC)
+permissions_title: "Permissions"
+permissions_subtitle: "Control who can access this dossier and what they can do"
+current_access: "Current access"
+grant_access: "Grant access"
+no_grantees: "No one else has access to this dossier."
+person_email: "Email address"
+person_email_hint: "If they don't have an account, they'll be invited to create one."
+person_name: "Name"
+select_role: "Select a role..."
+custom_role: "Custom permissions"
+permissions: "Permissions"
+op_read: "Read"
+op_write: "Write"
+op_delete: "Delete"
+op_manage: "Manage"
+grant: "Grant access"
+revoke: "Revoke"
+role_descriptions: "Role descriptions"
+ops_legend: "Permission legend"
+op_read_desc: "View data"
+op_write_desc: "Add/edit data"
+op_delete_desc: "Remove data"
+op_manage_desc: "Manage who has access"
+permissions_updated: "Permissions updated successfully."
+back: "Back"
+can_add_data: "Can add data"
+install_title: "Connect to Claude"
+install_intro: "Set up the inou bridge to let Claude analyze your health data"
+install_step1: "Step 1: Download"
+install_step1_desc: "Get the bridge for your platform"
+install_download_intro: "Download the inou bridge for your operating system:"
+install_step2: "Step 2: Configure"
+install_step2_desc: "Add to Claude Desktop config"
+install_config_intro: "Add this to your Claude Desktop configuration file:"
+install_step3: "Step 3: Test"
+install_step3_desc: "Verify the connection"
+install_test_intro: "Restart Claude Desktop and ask: 'Show me my inou profiles'"
+nav_install: "Connect to Claude"
+nav_home: "Home"
+pending: "pending"
+rate_limit_exceeded: "Too many sign-up attempts from your location. Please try again tomorrow."
+section_genetics: Genetics
+no_genetics: No genetic data
+
+sex_male: "male"
+sex_female: "female"
+sex_na: "other"
+
+# Friend invite email
+friend_invite_subject: "Check this out — %s"
+friend_invite_p1: "I've been using inou, the secure way to store health data and explore it with AI. It keeps all my family's health information in one place — imaging studies, lab results, medical records — and I thought you might find it useful too."
+friend_invite_p2: "The real power is being able to use AI to make sense of it all: understand what a report actually means, spot trends over time, or just ask questions in plain language and get clear answers."
+friend_invite_btn: "Check out inou"
+friend_invite_dear: "Dear %s,"
+rel_0: "you"
+rel_1: "Parent"
+rel_2: "Child"
+rel_3: "Spouse"
+rel_4: "Sibling"
+rel_5: "Guardian"
+rel_6: "Caregiver"
+rel_7: "Coach"
+rel_8: "Doctor"
+rel_9: "Friend"
+rel_10: "Other"
+rel_99: "Demo"
+select_relation: "Select relationship..."
+audit_dossier_added: "A new dossier for %s created by %s"
+audit_dossier_edited: "Dossier %s edited by %s"
+audit_access_granted: "Access to %s granted to %s"
+audit_dossier_created: Account created by %s
+audit_access_revoked: Access for %s to %s revoked
+audit_file_upload: File %s uploaded by %s
+audit_file_delete: File %s deleted by %s
+audit_file_category_change: File %s category changed by %s
+audit_genome_import: %s genetic variants imported
+
+# Categories (category000 = imaging, etc.)
+category000: Imaging
+category001: Document
+category002: Lab result
+category003: Genome
+category004: Upload
+category005: Consultation
+category006: Diagnosis
+category007: Imaging finding
+category008: EEG finding
+category009: Vital sign
+category010: Exercise
+category011: Medication
+category012: Supplement
+category013: Nutrition
+category014: Fertility
+category015: Symptom
+category016: Note
+category017: Medical history
+category018: Family history
+category019: Surgery
+category020: Hospitalization
+category021: Birth record
+category022: Medical device
+category023: Therapy
+category024: Assessment
+category025: Provider
+category026: Question
+
+# Genome
+genome_english_only: ""
+genome_variants: "variants"
+genome_hidden: "hidden"
+genome_show_all_categories: "Show all %d categories"
+
+# API
+api_token: "API Token"
+api_token_use: "Use this token to authenticate API requests:"
+api_token_warning: "Keep this private. Anyone with this token can access your health data."
+api_token_none: "Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/es.yaml b/lang/es.yaml
new file mode 100644
index 0000000..f5e0f89
--- /dev/null
+++ b/lang/es.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Tus datos de salud."
+headline_2: "Tu IA."
+headline_3: "Tus respuestas."
+intro: "Sube imágenes médicas, análisis y más. Conecta tu IA para ayudarte a entender lo que estás viendo."
+email: "Correo electrónico"
+get_started: "Comenzar"
+data_yours: "Tus datos son tuyos"
+never_training: "Nunca usados para entrenamiento"
+never_training_desc: "Tus imágenes nunca se usan para entrenar modelos de IA."
+never_shared: "Nunca compartidos"
+never_shared_desc: "Nunca compartimos tus datos con nadie."
+encrypted: "Almacenamiento cifrado"
+encrypted_desc: "Todos los datos cifrados en reposo."
+delete: "Eliminar en cualquier momento"
+delete_desc: "Tus datos, tu control."
+
+# Verify
+check_email: "Revisa tu correo"
+code_sent_to: "Enviamos un código de 6 dígitos a"
+verification_code: "Código de verificación"
+verify: "Verificar"
+use_different_email: "Usar otro correo"
+invalid_code: "Código inválido o expirado. Por favor, inténtalo de nuevo."
+
+# Onboard
+create_dossier: "Crea tu expediente"
+create_profile_intro: "Cuéntanos sobre ti para comenzar."
+name: "Nombre"
+name_placeholder: "Tu nombre"
+date_of_birth: "Fecha de nacimiento"
+sex_at_birth: "Sexo al nacer"
+female: "Femenino"
+male: "Masculino"
+create_my_dossier: "Crear mi expediente"
+
+# Minor error
+must_be_18: "Debes tener 18 años para crear una cuenta"
+minor_explanation: "Si estás configurando esto para otra persona, comienza primero con tu propio perfil. Esto asegura que solo tú puedas acceder a sus datos de salud."
+minor_next_steps: "Después de crear tu expediente, puedes agregar otros."
+use_different_dob: "Usar otra fecha de nacimiento"
+
+# Minor login block
+minor_login_blocked: "Debes tener 18 años para iniciar sesión"
+minor_ask_guardian: "Pide a %s que acceda a tu expediente."
+minor_ask_guardian_generic: "Pide a un padre o tutor que acceda a tu expediente."
+
+# Dashboard
+dossiers: "Expedientes"
+dossiers_intro: "Gestiona datos de salud para ti o para otros"
+you: "tú"
+view: "Ver"
+save: "Guardar"
+cancel: "Cancelar"
+add_dossier: "Agregar expediente"
+edit_dossier: "Editar expediente"
+care: "cuidado"
+logout: "Cerrar sesión"
+
+# Profile detail
+back_to_dossiers: "Volver a expedientes"
+born: "Nacido/a"
+no_access_yet: "Solo tú tienes acceso."
+people_with_access: "Personas con acceso"
+share_access: "Compartir acceso"
+can_edit: "puede agregar datos"
+remove: "Eliminar"
+confirm_revoke: "¿Eliminar acceso?"
+
+# Dossier sections
+section_imaging: "Imágenes"
+section_labs: "Laboratorio"
+section_uploads: "Archivos"
+section_vitals: "Signos vitales"
+section_medications: "Medicamentos"
+section_records: "Registros"
+section_journal: "Diario"
+section_genetics: "Genética"
+section_privacy: "Privacidad"
+
+# Section summaries
+imaging_summary: "%d estudios · %d cortes"
+no_imaging: "Sin datos de imágenes"
+no_lab_data: "Sin datos de laboratorio"
+no_genetics: "Sin datos genéticos"
+no_files: "Sin archivos"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d archivos (%s)"
+series_count: "%d series"
+vitals_desc: "Presión arterial, frecuencia cardíaca, SpO₂, peso, glucosa"
+medications_desc: "Recetas y suplementos"
+records_desc: "Notas clínicas e historiales médicos"
+journal_desc: "Síntomas, dolor y observaciones"
+
+# Buttons and actions
+open_viewer: "Abrir visor"
+manage: "Gestionar"
+show_all_studies: "Mostrar los %d estudios..."
+coming_soon: "Próximamente"
+
+# Upload page
+upload_files: "Subir datos de salud"
+upload_files_intro: "Sube imágenes médicas, resultados de laboratorio, archivos genómicos o cualquier documento relacionado con la salud."
+upload_hint_broad: "DICOM, PDF, CSV, VCF y más"
+uploading: "Subiendo..."
+files_uploaded: "archivos subidos"
+upload_scans: "Subir estudios"
+upload_scans_intro: "Sube una carpeta con archivos DICOM de tu estudio de imágenes."
+upload_drop: "Haz clic o arrastra una carpeta aquí"
+upload_hint: "Solo carpetas DICOM"
+
+# Add profile
+add_dossier_intro: "Agrega a alguien cuyos datos de salud quieras gestionar."
+email_optional: "Correo (opcional)"
+email_optional_hint: "Si tienen 18, pueden iniciar sesión ellos mismos"
+your_relation: "Tu relación con esta persona"
+select_relation: "Seleccionar..."
+i_provide_care: "Proporciono cuidado a esta persona"
+i_am_their: "Soy su..."
+
+# Share access
+share_access_intro: "Invitar a alguien a acceder"
+their_relation: "Su relación con esta persona"
+can_add_data: "Puede agregar datos (suplementos, notas, etc.)"
+send_invitation: "Enviar invitación"
+back_to_dossier: "Volver al expediente"
+
+# Relations
+my_role: "mi rol"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s te agregó a inou"
+invite_email_body: "%s agregó tu expediente de salud a inou para que puedas ver y gestionar tus datos médicos."
+invite_email_cta: "Iniciar sesión para ver"
+continue: "Continuar"
+
+# Access management
+people_with_access_count: "personas con acceso"
+view_audit_log: "Ver registro de actividad"
+export_data: "Download my data"
+relation_with: "Relación con"
+audit_log: "Registro de actividad"
+audit_log_intro: "Historial de actividad para"
+audit_log_desc: "Rastrea quién accedió o modificó este expediente"
+
+# Install / Connect
+install_title: "Conectar con Claude"
+install_intro: "Configura el puente inou para que Claude analice tus datos de salud"
+install_step1: "Paso 1: Descargar"
+install_step1_desc: "Obtén el puente para tu plataforma"
+install_download_intro: "Descarga el puente inou para tu sistema operativo:"
+install_step2: "Paso 2: Configurar"
+install_step2_desc: "Agregar a la configuración de Claude Desktop"
+install_config_intro: "Agrega esto a tu archivo de configuración de Claude Desktop:"
+install_step3: "Paso 3: Probar"
+install_step3_desc: "Verificar la conexión"
+install_test_intro: "Reinicia Claude Desktop y pregunta: 'Muéstrame mis perfiles de inou'"
+nav_install: "Conectar con Claude"
+nav_home: "Inicio"
+
+# Status
+pending: "pendiente"
+rate_limit_exceeded: "Demasiados intentos de registro desde tu ubicación. Por favor, inténtalo mañana."
+
+# Sex display
+sex_male: "masculino"
+sex_female: "femenino"
+sex_na: "otro"
+
+# Friend invite email
+friend_invite_subject: "Mira esto — %s"
+friend_invite_p1: "Estoy usando inou, la forma segura de guardar datos de salud y explorarlos con IA. Mantiene toda la información de salud de mi familia en un solo lugar — estudios de imagen, resultados de laboratorio, historiales médicos — y pensé que también te podría ser útil."
+friend_invite_p2: "El verdadero poder está en poder usar IA para entenderlo todo: comprender qué significa realmente un informe, detectar tendencias a lo largo del tiempo, o simplemente hacer preguntas en lenguaje sencillo y obtener respuestas claras."
+friend_invite_btn: "Descubre inou"
+friend_invite_dear: "Querido/a %s,"
+rel_0: "tú"
+rel_1: "Padre/Madre"
+rel_2: "Hijo/a"
+rel_3: "Cónyuge"
+rel_4: "Hermano/a"
+rel_5: "Tutor"
+rel_6: "Cuidador"
+rel_7: "Coach"
+rel_8: "Médico"
+rel_9: "Amigo"
+rel_10: "Otro"
+rel_99: "Demo"
+select_relation: "Seleccionar relación..."
+
+# Categorías
+category000: Imagen médica
+category001: Documento
+category002: Resultado de laboratorio
+category003: Genoma
+category004: Carga
+category005: Consulta
+category006: Diagnóstico
+category007: Resultado de imagen
+category008: Resultado de EEG
+category009: Signo vital
+category010: Ejercicio
+category011: Medicamento
+category012: Suplemento
+category013: Nutrición
+category014: Fertilidad
+category015: Síntoma
+category016: Nota
+category017: Historial médico
+category018: Antecedentes familiares
+category019: Cirugía
+category020: Hospitalización
+category021: Datos de nacimiento
+category022: Dispositivo médico
+category023: Terapia
+category024: Evaluación
+category025: Proveedor de salud
+category026: Pregunta
+
+# Genome
+genome_english_only: "Toda la información genética está en inglés. Usa Claude para discutirla en español."
+genome_variants: "variantes"
+genome_hidden: "ocultas"
+genome_show_all_categories: "Mostrar las %d categorías"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/fi.yaml b/lang/fi.yaml
new file mode 100644
index 0000000..87c6b0c
--- /dev/null
+++ b/lang/fi.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Sinun terveystietosi."
+headline_2: "Sinun tekoälysi."
+headline_3: "Sinun vastauksesi."
+intro: "Lataa kuvantamista, laboratoriotuloksia ja muuta. Yhdistä tekoäly auttamaan ymmärtämään, mitä näet."
+email: "Sähköposti"
+get_started: "Aloita"
+data_yours: "Tietosi pysyvät sinun"
+never_training: "Ei koskaan käytetä koulutukseen"
+never_training_desc: "Kuviasi ei koskaan käytetä tekoälymallien koulutukseen."
+never_shared: "Ei koskaan jaeta"
+never_shared_desc: "Emme koskaan jaa tietojasi kenellekään."
+encrypted: "Salattu tallennus"
+encrypted_desc: "Kaikki tiedot salattu levossa."
+delete: "Poista milloin tahansa"
+delete_desc: "Sinun tietosi, sinun hallintasi."
+
+# Verify
+check_email: "Tarkista sähköpostisi"
+code_sent_to: "Lähetimme 6-numeroisen koodin osoitteeseen"
+verification_code: "Vahvistuskoodi"
+verify: "Vahvista"
+use_different_email: "Käytä toista sähköpostia"
+invalid_code: "Virheellinen tai vanhentunut koodi. Yritä uudelleen."
+
+# Onboard
+create_dossier: "Luo kansiosi"
+create_profile_intro: "Kerro meille itsestäsi aloittaaksesi."
+name: "Nimi"
+name_placeholder: "Nimesi"
+date_of_birth: "Syntymäaika"
+sex_at_birth: "Sukupuoli syntymähetkellä"
+female: "Nainen"
+male: "Mies"
+create_my_dossier: "Luo kansioni"
+
+# Minor error
+must_be_18: "Sinun täytyy olla 18 luodaksesi tilin"
+minor_explanation: "Jos luot tämän jollekin toiselle, aloita omasta profiilistasi ensin. Tämä varmistaa, että vain sinä voit käyttää heidän terveystietojaan."
+minor_next_steps: "Kansion luomisen jälkeen voit lisätä muita."
+use_different_dob: "Käytä toista syntymäaikaa"
+
+# Minor login block
+minor_login_blocked: "Sinun täytyy olla 18 kirjautuaksesi sisään"
+minor_ask_guardian: "Pyydä %s pääsyä kansioosi."
+minor_ask_guardian_generic: "Pyydä vanhempaa tai huoltajaa pääsyä kansioosi."
+
+# Dashboard
+dossiers: "Kansiot"
+dossiers_intro: "Hallitse terveystietoja itsellesi tai muille"
+you: "sinä"
+view: "Näytä"
+save: "Tallenna"
+cancel: "Peruuta"
+add_dossier: "Lisää kansio"
+edit_dossier: "Muokkaa kansiota"
+care: "hoito"
+logout: "Kirjaudu ulos"
+
+# Profile detail
+back_to_dossiers: "Takaisin kansioihin"
+born: "Syntynyt"
+no_access_yet: "Vain sinulla on pääsy."
+people_with_access: "Henkilöt, joilla on pääsy"
+share_access: "Jaa pääsy"
+can_edit: "voi lisätä tietoja"
+remove: "Poista"
+confirm_revoke: "Poista pääsy?"
+
+# Dossier sections
+section_imaging: "Kuvantaminen"
+section_labs: "Laboratorio"
+section_uploads: "Lataukset"
+section_vitals: "Elintoiminnot"
+section_medications: "Lääkkeet"
+section_records: "Asiakirjat"
+section_journal: "Päiväkirja"
+section_genetics: "Genetiikka"
+section_privacy: "Yksityisyys"
+
+# Section summaries
+imaging_summary: "%d tutkimusta · %d leikettä"
+no_imaging: "Ei kuvatietoja"
+no_lab_data: "Ei laboratoriotietoja"
+no_genetics: "Ei geneettisiä tietoja"
+no_files: "Ei tiedostoja"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d tiedostoa (%s)"
+series_count: "%d sarjaa"
+vitals_desc: "Verenpaine, syke, SpO₂, paino, verensokeri"
+medications_desc: "Reseptit ja ravintolisät"
+records_desc: "Kliiniset muistiinpanot ja potilasasiakirjat"
+journal_desc: "Oireet, kipu ja havainnot"
+
+# Buttons and actions
+open_viewer: "Avaa katselin"
+manage: "Hallitse"
+show_all_studies: "Näytä kaikki %d tutkimusta..."
+coming_soon: "Tulossa pian"
+
+# Upload page
+upload_files: "Lataa terveystietoja"
+upload_files_intro: "Lataa lääketieteellistä kuvantamista, laboratoriotuloksia, genomitiedostoja tai terveyteen liittyviä asiakirjoja."
+upload_hint_broad: "DICOM, PDF, CSV, VCF ja muut"
+uploading: "Ladataan..."
+files_uploaded: "tiedostoa ladattu"
+upload_scans: "Lataa kuvauksia"
+upload_scans_intro: "Lataa kansio, joka sisältää DICOM-tiedostoja kuvantamistutkimuksestasi."
+upload_drop: "Napsauta tai vedä kansio tähän"
+upload_hint: "Vain DICOM-kansiot"
+
+# Add profile
+add_dossier_intro: "Lisää henkilö, jonka terveystietoja haluat hallita."
+email_optional: "Sähköposti (valinnainen)"
+email_optional_hint: "Jos he ovat 18, he voivat kirjautua itse"
+your_relation: "Suhteesi heihin"
+select_relation: "Valitse..."
+i_provide_care: "Hoidan tätä henkilöä"
+i_am_their: "Olen heidän..."
+
+# Share access
+share_access_intro: "Kutsu joku käyttämään"
+their_relation: "Heidän suhteensa tähän henkilöön"
+can_add_data: "Voi lisätä tietoja (ravintolisät, muistiinpanot, jne.)"
+send_invitation: "Lähetä kutsu"
+back_to_dossier: "Takaisin kansioon"
+
+# Relations
+my_role: "roolini"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s lisäsi sinut inouun"
+invite_email_body: "%s lisäsi terveyskansiosi inouun, jotta voit katsella ja hallita lääketieteellisiä tietojasi."
+invite_email_cta: "Kirjaudu sisään nähdäksesi"
+continue: "Jatka"
+
+# Access management
+people_with_access_count: "henkilöä, joilla on pääsy"
+view_audit_log: "Näytä tapahtumaloki"
+export_data: "Download my data"
+relation_with: "Suhde henkilöön"
+audit_log: "Tapahtumaloki"
+audit_log_intro: "Toimintahistoria"
+audit_log_desc: "Seuraa, kuka on käyttänyt tai muokannut tätä kansiota"
+
+# Install / Connect
+install_title: "Yhdistä Claudeen"
+install_intro: "Määritä inou-silta, jotta Claude voi analysoida terveystietojasi"
+install_step1: "Vaihe 1: Lataa"
+install_step1_desc: "Hanki silta alustallesi"
+install_download_intro: "Lataa inou-silta käyttöjärjestelmällesi:"
+install_step2: "Vaihe 2: Määritä"
+install_step2_desc: "Lisää Claude Desktop -asetuksiin"
+install_config_intro: "Lisää tämä Claude Desktop -asetustiedostoosi:"
+install_step3: "Vaihe 3: Testaa"
+install_step3_desc: "Vahvista yhteys"
+install_test_intro: "Käynnistä Claude Desktop uudelleen ja kysy: 'Näytä inou-profiilini'"
+nav_install: "Yhdistä Claudeen"
+nav_home: "Koti"
+
+# Status
+pending: "odottaa"
+rate_limit_exceeded: "Liian monta rekisteröitymisyritystä sijainnistasi. Yritä uudelleen huomenna."
+
+# Sex display
+sex_male: "mies"
+sex_female: "nainen"
+sex_na: "muu"
+
+# Friend invite email
+friend_invite_subject: "Katso tämä — %s"
+friend_invite_p1: "Olen käyttänyt inoua, turvallista tapaa tallentaa terveystietoja ja tutkia niitä tekoälyn avulla. Se pitää kaikki perheeni terveystiedot yhdessä paikassa — kuvantamistutkimukset, laboratoriotulokset, potilaskertomukset — ja ajattelin, että siitä voisi olla hyötyä sinullekin."
+friend_invite_p2: "Todellinen voima on siinä, että voit käyttää tekoälyä ymmärtääksesi kaiken: ymmärtää mitä raportti todella tarkoittaa, havaita trendejä ajan myötä, tai vain esittää kysymyksiä tavallisella suomella ja saada selkeitä vastauksia."
+friend_invite_btn: "Tutustu inouun"
+friend_invite_dear: "Hei %s,"
+rel_0: "sinä"
+rel_1: "Vanhempi"
+rel_2: "Lapsi"
+rel_3: "Puoliso"
+rel_4: "Sisarus"
+rel_5: "Huoltaja"
+rel_6: "Hoitaja"
+rel_7: "Valmentaja"
+rel_8: "Lääkäri"
+rel_9: "Ystävä"
+rel_10: "Muu"
+rel_99: "Demo"
+select_relation: "Valitse suhde..."
+
+# Kategoriat
+category000: Kuvantaminen
+category001: Asiakirja
+category002: Laboratoriotulos
+category003: Genomi
+category004: Lataus
+category005: Konsultaatio
+category006: Diagnoosi
+category007: Kuvantamistulos
+category008: EEG-tulos
+category009: Elintoiminto
+category010: Liikunta
+category011: Lääke
+category012: Lisäravinne
+category013: Ravitsemus
+category014: Hedelmällisyys
+category015: Oire
+category016: Muistiinpano
+category017: Sairaushistoria
+category018: Sukuhistoria
+category019: Leikkaus
+category020: Sairaalahoito
+category021: Syntymätiedot
+category022: Lääkinnällinen laite
+category023: Terapia
+category024: Arviointi
+category025: Terveydenhuollon tarjoaja
+category026: Kysymys
+
+# Genome
+genome_english_only: "Kaikki geneettinen tieto on englanniksi. Käytä Claudea keskustellaksesi siitä suomeksi."
+genome_variants: "varianttia"
+genome_hidden: "piilotettua"
+genome_show_all_categories: "Näytä kaikki %d kategoriaa"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/fr.yaml b/lang/fr.yaml
new file mode 100644
index 0000000..451e198
--- /dev/null
+++ b/lang/fr.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Vos données de santé."
+headline_2: "Votre IA."
+headline_3: "Vos réponses."
+intro: "Téléchargez imagerie, analyses et plus encore. Connectez votre IA pour vous aider à comprendre ce que vous regardez."
+email: "E-mail"
+get_started: "Commencer"
+data_yours: "Vos données restent les vôtres"
+never_training: "Jamais utilisées pour l'entraînement"
+never_training_desc: "Vos images ne sont jamais utilisées pour entraîner des modèles d'IA."
+never_shared: "Jamais partagées"
+never_shared_desc: "Nous ne partageons jamais vos données avec personne."
+encrypted: "Stockage chiffré"
+encrypted_desc: "Toutes les données sont chiffrées au repos."
+delete: "Supprimer à tout moment"
+delete_desc: "Vos données, votre contrôle."
+
+# Verify
+check_email: "Vérifiez votre e-mail"
+code_sent_to: "Nous avons envoyé un code à 6 chiffres à"
+verification_code: "Code de vérification"
+verify: "Vérifier"
+use_different_email: "Utiliser un autre e-mail"
+invalid_code: "Code invalide ou expiré. Veuillez réessayer."
+
+# Onboard
+create_dossier: "Créez votre dossier"
+create_profile_intro: "Parlez-nous de vous pour commencer."
+name: "Nom"
+name_placeholder: "Votre nom"
+date_of_birth: "Date de naissance"
+sex_at_birth: "Sexe à la naissance"
+female: "Féminin"
+male: "Masculin"
+create_my_dossier: "Créer mon dossier"
+
+# Minor error
+must_be_18: "Vous devez avoir 18 ans pour créer un compte"
+minor_explanation: "Si vous configurez ceci pour quelqu'un d'autre, commencez d'abord par votre propre profil. Cela garantit que vous seul pouvez accéder à leurs données de santé."
+minor_next_steps: "Après avoir créé votre dossier, vous pouvez en ajouter d'autres."
+use_different_dob: "Utiliser une autre date de naissance"
+
+# Minor login block
+minor_login_blocked: "Vous devez avoir 18 ans pour vous connecter"
+minor_ask_guardian: "Demandez à %s d'accéder à votre dossier."
+minor_ask_guardian_generic: "Demandez à un parent ou tuteur d'accéder à votre dossier."
+
+# Dashboard
+dossiers: "Dossiers"
+dossiers_intro: "Gérez les données de santé pour vous-même ou pour d'autres"
+you: "vous"
+view: "Voir"
+save: "Enregistrer"
+cancel: "Annuler"
+add_dossier: "Ajouter un dossier"
+edit_dossier: "Modifier le dossier"
+care: "soins"
+logout: "Se déconnecter"
+
+# Profile detail
+back_to_dossiers: "Retour aux dossiers"
+born: "Né(e)"
+no_access_yet: "Vous seul avez accès."
+people_with_access: "Personnes ayant accès"
+share_access: "Partager l'accès"
+can_edit: "peut ajouter des données"
+remove: "Supprimer"
+confirm_revoke: "Supprimer l'accès ?"
+
+# Dossier sections
+section_imaging: "Imagerie"
+section_labs: "Analyses"
+section_uploads: "Fichiers"
+section_vitals: "Signes vitaux"
+section_medications: "Médicaments"
+section_records: "Dossiers"
+section_journal: "Journal"
+section_genetics: "Génétique"
+section_privacy: "Confidentialité"
+
+# Section summaries
+imaging_summary: "%d études · %d coupes"
+no_imaging: "Aucune donnée d'imagerie"
+no_lab_data: "Aucune donnée de laboratoire"
+no_genetics: "Aucune donnée génétique"
+no_files: "Aucun fichier"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d fichiers (%s)"
+series_count: "%d séries"
+vitals_desc: "Tension artérielle, fréquence cardiaque, SpO₂, poids, glycémie"
+medications_desc: "Ordonnances et compléments alimentaires"
+records_desc: "Notes cliniques et dossiers médicaux"
+journal_desc: "Symptômes, douleurs et observations"
+
+# Buttons and actions
+open_viewer: "Ouvrir le visualiseur"
+manage: "Gérer"
+show_all_studies: "Afficher les %d études..."
+coming_soon: "Bientôt disponible"
+
+# Upload page
+upload_files: "Télécharger des données de santé"
+upload_files_intro: "Téléchargez imagerie médicale, résultats d'analyses, fichiers génomiques ou tout document lié à la santé."
+upload_hint_broad: "DICOM, PDF, CSV, VCF et plus"
+uploading: "Téléchargement..."
+files_uploaded: "fichiers téléchargés"
+upload_scans: "Télécharger des examens"
+upload_scans_intro: "Téléchargez un dossier contenant des fichiers DICOM de votre étude d'imagerie."
+upload_drop: "Cliquez ou glissez un dossier ici"
+upload_hint: "Dossiers DICOM uniquement"
+
+# Add profile
+add_dossier_intro: "Ajoutez quelqu'un dont vous souhaitez gérer les données de santé."
+email_optional: "E-mail (optionnel)"
+email_optional_hint: "S'ils ont 18 ans, ils peuvent se connecter eux-mêmes"
+your_relation: "Votre relation avec cette personne"
+select_relation: "Sélectionner..."
+i_provide_care: "Je m'occupe de cette personne"
+i_am_their: "Je suis son/sa..."
+
+# Share access
+share_access_intro: "Inviter quelqu'un à accéder"
+their_relation: "Leur relation avec cette personne"
+can_add_data: "Peut ajouter des données (compléments, notes, etc.)"
+send_invitation: "Envoyer l'invitation"
+back_to_dossier: "Retour au dossier"
+
+# Relations
+my_role: "mon rôle"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s vous a ajouté à inou"
+invite_email_body: "%s a ajouté votre dossier de santé à inou afin que vous puissiez consulter et gérer vos données médicales."
+invite_email_cta: "Se connecter pour voir"
+continue: "Continuer"
+
+# Access management
+people_with_access_count: "personnes ayant accès"
+view_audit_log: "Voir le journal d'activité"
+export_data: "Download my data"
+relation_with: "Relation avec"
+audit_log: "Journal d'activité"
+audit_log_intro: "Historique d'activité pour"
+audit_log_desc: "Suivez qui a accédé ou modifié ce dossier"
+
+# Install / Connect
+install_title: "Connecter à Claude"
+install_intro: "Configurez le pont inou pour permettre à Claude d'analyser vos données de santé"
+install_step1: "Étape 1 : Télécharger"
+install_step1_desc: "Obtenez le pont pour votre plateforme"
+install_download_intro: "Téléchargez le pont inou pour votre système d'exploitation :"
+install_step2: "Étape 2 : Configurer"
+install_step2_desc: "Ajouter à la configuration de Claude Desktop"
+install_config_intro: "Ajoutez ceci à votre fichier de configuration Claude Desktop :"
+install_step3: "Étape 3 : Tester"
+install_step3_desc: "Vérifier la connexion"
+install_test_intro: "Redémarrez Claude Desktop et demandez : 'Montre-moi mes profils inou'"
+nav_install: "Connecter à Claude"
+nav_home: "Accueil"
+
+# Status
+pending: "en attente"
+rate_limit_exceeded: "Trop de tentatives d'inscription depuis votre emplacement. Veuillez réessayer demain."
+
+# Sex display
+sex_male: "masculin"
+sex_female: "féminin"
+sex_na: "autre"
+
+# Friend invite email
+friend_invite_subject: "Regarde ça — %s"
+friend_invite_p1: "J'utilise inou, la façon sécurisée de stocker des données de santé et de les explorer avec l'IA. Ça garde toutes les informations de santé de ma famille au même endroit — imagerie, résultats de labo, dossiers médicaux — et je me suis dit que ça pourrait t'être utile aussi."
+friend_invite_p2: "La vraie puissance, c'est de pouvoir utiliser l'IA pour tout comprendre : comprendre ce qu'un rapport signifie vraiment, repérer les tendances dans le temps, ou simplement poser des questions en langage courant et obtenir des réponses claires."
+friend_invite_btn: "Découvrir inou"
+friend_invite_dear: "Cher/Chère %s,"
+rel_0: "toi"
+rel_1: "Parent"
+rel_2: "Enfant"
+rel_3: "Conjoint"
+rel_4: "Frère/Sœur"
+rel_5: "Tuteur"
+rel_6: "Aidant"
+rel_7: "Coach"
+rel_8: "Médecin"
+rel_9: "Ami"
+rel_10: "Autre"
+rel_99: "Demo"
+select_relation: "Sélectionner la relation..."
+
+# Catégories
+category000: Imagerie
+category001: Document
+category002: Résultat de laboratoire
+category003: Génome
+category004: Téléchargement
+category005: Consultation
+category006: Diagnostic
+category007: Résultat d'imagerie
+category008: Résultat EEG
+category009: Signe vital
+category010: Exercice
+category011: Médicament
+category012: Supplément
+category013: Nutrition
+category014: Fertilité
+category015: Symptôme
+category016: Note
+category017: Antécédents médicaux
+category018: Antécédents familiaux
+category019: Chirurgie
+category020: Hospitalisation
+category021: Données de naissance
+category022: Dispositif médical
+category023: Thérapie
+category024: Évaluation
+category025: Prestataire de soins
+category026: Question
+
+# Genome
+genome_english_only: "Toutes les informations génétiques sont en anglais. Utilisez Claude pour en discuter en français."
+genome_variants: "variantes"
+genome_hidden: "masquées"
+genome_show_all_categories: "Afficher les %d catégories"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/it.yaml b/lang/it.yaml
new file mode 100644
index 0000000..7e77ef2
--- /dev/null
+++ b/lang/it.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "I tuoi dati sanitari."
+headline_2: "La tua IA."
+headline_3: "Le tue risposte."
+intro: "Carica immagini diagnostiche, analisi e altro. Connetti la tua IA per aiutarti a capire cosa stai guardando."
+email: "Email"
+get_started: "Inizia"
+data_yours: "I tuoi dati restano tuoi"
+never_training: "Mai usati per l'addestramento"
+never_training_desc: "Le tue immagini non vengono mai usate per addestrare modelli IA."
+never_shared: "Mai condivisi"
+never_shared_desc: "Non condividiamo mai i tuoi dati con nessuno."
+encrypted: "Archiviazione crittografata"
+encrypted_desc: "Tutti i dati crittografati a riposo."
+delete: "Elimina quando vuoi"
+delete_desc: "I tuoi dati, il tuo controllo."
+
+# Verify
+check_email: "Controlla la tua email"
+code_sent_to: "Abbiamo inviato un codice a 6 cifre a"
+verification_code: "Codice di verifica"
+verify: "Verifica"
+use_different_email: "Usa un'altra email"
+invalid_code: "Codice non valido o scaduto. Riprova."
+
+# Onboard
+create_dossier: "Crea il tuo dossier"
+create_profile_intro: "Parlaci di te per iniziare."
+name: "Nome"
+name_placeholder: "Il tuo nome"
+date_of_birth: "Data di nascita"
+sex_at_birth: "Sesso alla nascita"
+female: "Femmina"
+male: "Maschio"
+create_my_dossier: "Crea il mio dossier"
+
+# Minor error
+must_be_18: "Devi avere 18 anni per creare un account"
+minor_explanation: "Se stai configurando questo per qualcun altro, inizia prima con il tuo profilo. Questo assicura che solo tu possa accedere ai loro dati sanitari."
+minor_next_steps: "Dopo aver creato il tuo dossier, puoi aggiungerne altri."
+use_different_dob: "Usa un'altra data di nascita"
+
+# Minor login block
+minor_login_blocked: "Devi avere 18 anni per accedere"
+minor_ask_guardian: "Chiedi a %s di accedere al tuo dossier."
+minor_ask_guardian_generic: "Chiedi a un genitore o tutore di accedere al tuo dossier."
+
+# Dashboard
+dossiers: "Dossier"
+dossiers_intro: "Gestisci i dati sanitari per te o altri"
+you: "tu"
+view: "Visualizza"
+save: "Salva"
+cancel: "Annulla"
+add_dossier: "Aggiungi dossier"
+edit_dossier: "Modifica dossier"
+care: "assistenza"
+logout: "Esci"
+
+# Profile detail
+back_to_dossiers: "Torna ai dossier"
+born: "Nato/a"
+no_access_yet: "Solo tu hai accesso."
+people_with_access: "Persone con accesso"
+share_access: "Condividi accesso"
+can_edit: "può aggiungere dati"
+remove: "Rimuovi"
+confirm_revoke: "Rimuovere l'accesso?"
+
+# Dossier sections
+section_imaging: "Immagini"
+section_labs: "Analisi"
+section_uploads: "File"
+section_vitals: "Parametri vitali"
+section_medications: "Farmaci"
+section_records: "Cartelle"
+section_journal: "Diario"
+section_genetics: "Genetica"
+section_privacy: "Privacy"
+
+# Section summaries
+imaging_summary: "%d studi · %d sezioni"
+no_imaging: "Nessun dato di imaging"
+no_lab_data: "Nessun dato di laboratorio"
+no_genetics: "Nessun dato genetico"
+no_files: "Nessun file"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d file (%s)"
+series_count: "%d serie"
+vitals_desc: "Pressione sanguigna, frequenza cardiaca, SpO₂, peso, glicemia"
+medications_desc: "Prescrizioni e integratori"
+records_desc: "Note cliniche e cartelle mediche"
+journal_desc: "Sintomi, dolore e osservazioni"
+
+# Buttons and actions
+open_viewer: "Apri visualizzatore"
+manage: "Gestisci"
+show_all_studies: "Mostra tutti i %d studi..."
+coming_soon: "Prossimamente"
+
+# Upload page
+upload_files: "Carica dati sanitari"
+upload_files_intro: "Carica immagini mediche, risultati di laboratorio, file genomici o documenti sanitari."
+upload_hint_broad: "DICOM, PDF, CSV, VCF e altro"
+uploading: "Caricamento..."
+files_uploaded: "file caricati"
+upload_scans: "Carica scansioni"
+upload_scans_intro: "Carica una cartella contenente file DICOM dal tuo studio di imaging."
+upload_drop: "Clicca o trascina una cartella qui"
+upload_hint: "Solo cartelle DICOM"
+
+# Add profile
+add_dossier_intro: "Aggiungi qualcuno di cui vuoi gestire i dati sanitari."
+email_optional: "Email (opzionale)"
+email_optional_hint: "Se ha 18 anni, può accedere autonomamente"
+your_relation: "La tua relazione con questa persona"
+select_relation: "Seleziona..."
+i_provide_care: "Mi prendo cura di questa persona"
+i_am_their: "Sono il/la loro..."
+
+# Share access
+share_access_intro: "Invita qualcuno ad accedere"
+their_relation: "La loro relazione con questa persona"
+can_add_data: "Può aggiungere dati (integratori, note, ecc.)"
+send_invitation: "Invia invito"
+back_to_dossier: "Torna al dossier"
+
+# Relations
+my_role: "il mio ruolo"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s ti ha aggiunto a inou"
+invite_email_body: "%s ha aggiunto il tuo dossier sanitario a inou così puoi visualizzare e gestire i tuoi dati medici."
+invite_email_cta: "Accedi per visualizzare"
+continue: "Continua"
+
+# Access management
+people_with_access_count: "persone con accesso"
+view_audit_log: "Visualizza registro attività"
+export_data: "Download my data"
+relation_with: "Relazione con"
+audit_log: "Registro attività"
+audit_log_intro: "Cronologia attività per"
+audit_log_desc: "Traccia chi ha acceduto o modificato questo dossier"
+
+# Install / Connect
+install_title: "Connetti a Claude"
+install_intro: "Configura il bridge inou per permettere a Claude di analizzare i tuoi dati sanitari"
+install_step1: "Passo 1: Scarica"
+install_step1_desc: "Ottieni il bridge per la tua piattaforma"
+install_download_intro: "Scarica il bridge inou per il tuo sistema operativo:"
+install_step2: "Passo 2: Configura"
+install_step2_desc: "Aggiungi alla configurazione di Claude Desktop"
+install_config_intro: "Aggiungi questo al file di configurazione di Claude Desktop:"
+install_step3: "Passo 3: Testa"
+install_step3_desc: "Verifica la connessione"
+install_test_intro: "Riavvia Claude Desktop e chiedi: 'Mostrami i miei profili inou'"
+nav_install: "Connetti a Claude"
+nav_home: "Home"
+
+# Status
+pending: "in attesa"
+rate_limit_exceeded: "Troppi tentativi di registrazione dalla tua posizione. Riprova domani."
+
+# Sex display
+sex_male: "maschio"
+sex_female: "femmina"
+sex_na: "altro"
+
+# Friend invite email
+friend_invite_subject: "Dai un'occhiata — %s"
+friend_invite_p1: "Sto usando inou, il modo sicuro per archiviare dati sanitari ed esplorarli con l'IA. Tiene tutte le informazioni sulla salute della mia famiglia in un unico posto — esami di imaging, risultati di laboratorio, cartelle cliniche — e ho pensato che potrebbe essere utile anche a te."
+friend_invite_p2: "Il vero potere sta nel poter usare l'IA per dare senso a tutto: capire cosa significa veramente un referto, individuare tendenze nel tempo, o semplicemente fare domande in linguaggio semplice e ottenere risposte chiare."
+friend_invite_btn: "Scopri inou"
+friend_invite_dear: "Caro/a %s,"
+rel_0: "tu"
+rel_1: "Genitore"
+rel_2: "Figlio/a"
+rel_3: "Coniuge"
+rel_4: "Fratello/Sorella"
+rel_5: "Tutore"
+rel_6: "Caregiver"
+rel_7: "Coach"
+rel_8: "Medico"
+rel_9: "Amico"
+rel_10: "Altro"
+rel_99: "Demo"
+select_relation: "Seleziona relazione..."
+
+# Categorie
+category000: Diagnostica per immagini
+category001: Documento
+category002: Risultato di laboratorio
+category003: Genoma
+category004: Caricamento
+category005: Consultazione
+category006: Diagnosi
+category007: Risultato di imaging
+category008: Risultato EEG
+category009: Segno vitale
+category010: Esercizio
+category011: Farmaco
+category012: Integratore
+category013: Nutrizione
+category014: Fertilità
+category015: Sintomo
+category016: Nota
+category017: Storia medica
+category018: Storia familiare
+category019: Chirurgia
+category020: Ospedalizzazione
+category021: Dati di nascita
+category022: Dispositivo medico
+category023: Terapia
+category024: Valutazione
+category025: Fornitore sanitario
+category026: Domanda
+
+# Genome
+genome_english_only: "Tutte le informazioni genetiche sono in inglese. Usa Claude per discuterne in italiano."
+genome_variants: "varianti"
+genome_hidden: "nascoste"
+genome_show_all_categories: "Mostra tutte le %d categorie"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/ja.yaml b/lang/ja.yaml
new file mode 100644
index 0000000..9294b5e
--- /dev/null
+++ b/lang/ja.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "あなたの健康データ。"
+headline_2: "あなたのAI。"
+headline_3: "あなたの答え。"
+intro: "画像診断、検査結果などをアップロード。AIに接続して、見ているものを理解する手助けをしてもらいましょう。"
+email: "メールアドレス"
+get_started: "始める"
+data_yours: "あなたのデータはあなたのもの"
+never_training: "トレーニングには使用されません"
+never_training_desc: "あなたの画像はAIモデルのトレーニングには使用されません。"
+never_shared: "共有されません"
+never_shared_desc: "あなたのデータは誰とも共有しません。"
+encrypted: "暗号化ストレージ"
+encrypted_desc: "すべてのデータは保存時に暗号化されます。"
+delete: "いつでも削除可能"
+delete_desc: "あなたのデータ、あなたの管理。"
+
+# Verify
+check_email: "メールを確認してください"
+code_sent_to: "6桁のコードを送信しました:"
+verification_code: "確認コード"
+verify: "確認"
+use_different_email: "別のメールアドレスを使用"
+invalid_code: "無効または期限切れのコードです。もう一度お試しください。"
+
+# Onboard
+create_dossier: "ドシエを作成"
+create_profile_intro: "始めるにあたって、あなたについて教えてください。"
+name: "名前"
+name_placeholder: "あなたの名前"
+date_of_birth: "生年月日"
+sex_at_birth: "出生時の性別"
+female: "女性"
+male: "男性"
+create_my_dossier: "ドシエを作成"
+
+# Minor error
+must_be_18: "アカウントを作成するには18歳以上である必要があります"
+minor_explanation: "他の人のために設定する場合は、まず自分のプロフィールから始めてください。これにより、あなただけが彼らの健康データにアクセスできるようになります。"
+minor_next_steps: "ドシエを作成した後、他の人を追加できます。"
+use_different_dob: "別の生年月日を使用"
+
+# Minor login block
+minor_login_blocked: "ログインするには18歳以上である必要があります"
+minor_ask_guardian: "%sにドシエへのアクセスを依頼してください。"
+minor_ask_guardian_generic: "親または保護者にドシエへのアクセスを依頼してください。"
+
+# Dashboard
+dossiers: "ドシエ"
+dossiers_intro: "自分や他の人の健康データを管理"
+you: "あなた"
+view: "表示"
+save: "保存"
+cancel: "キャンセル"
+add_dossier: "ドシエを追加"
+edit_dossier: "ドシエを編集"
+care: "ケア"
+logout: "ログアウト"
+
+# Profile detail
+back_to_dossiers: "ドシエに戻る"
+born: "生年月日"
+no_access_yet: "アクセスできるのはあなただけです。"
+people_with_access: "アクセス権のある人"
+share_access: "アクセスを共有"
+can_edit: "データを追加可能"
+remove: "削除"
+confirm_revoke: "アクセスを削除しますか?"
+
+# Dossier sections
+section_imaging: "画像診断"
+section_labs: "検査結果"
+section_uploads: "アップロード"
+section_vitals: "バイタル"
+section_medications: "薬"
+section_records: "記録"
+section_journal: "日記"
+section_genetics: "遺伝子"
+section_privacy: "プライバシー"
+
+# Section summaries
+imaging_summary: "%d件の検査 · %dスライス"
+no_imaging: "画像データなし"
+no_lab_data: "検査データなし"
+no_genetics: "遺伝子データなし"
+no_files: "ファイルなし"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%dファイル(%s)"
+series_count: "%dシリーズ"
+vitals_desc: "血圧、心拍数、SpO₂、体重、血糖値"
+medications_desc: "処方薬とサプリメント"
+records_desc: "診療記録と医療記録"
+journal_desc: "症状、痛み、観察"
+
+# Buttons and actions
+open_viewer: "ビューアを開く"
+manage: "管理"
+show_all_studies: "すべての%d件の検査を表示..."
+coming_soon: "近日公開"
+
+# Upload page
+upload_files: "健康データをアップロード"
+upload_files_intro: "医療画像、検査結果、ゲノムファイル、または健康関連の文書をアップロードしてください。"
+upload_hint_broad: "DICOM、PDF、CSV、VCFなど"
+uploading: "アップロード中..."
+files_uploaded: "ファイルがアップロードされました"
+upload_scans: "スキャンをアップロード"
+upload_scans_intro: "画像検査のDICOMファイルを含むフォルダをアップロードしてください。"
+upload_drop: "クリックまたはフォルダをここにドラッグ"
+upload_hint: "DICOMフォルダのみ"
+
+# Add profile
+add_dossier_intro: "健康データを管理したい人を追加してください。"
+email_optional: "メール(任意)"
+email_optional_hint: "18歳以上なら、本人がログインできます"
+your_relation: "この人とのあなたの関係"
+select_relation: "選択..."
+i_provide_care: "この人のケアを提供しています"
+i_am_their: "私は彼らの..."
+
+# Share access
+share_access_intro: "アクセスする人を招待"
+their_relation: "この人との関係"
+can_add_data: "データを追加可能(サプリメント、メモなど)"
+send_invitation: "招待を送信"
+back_to_dossier: "ドシエに戻る"
+
+# Relations
+my_role: "私の役割"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%sがあなたをinouに追加しました"
+invite_email_body: "%sがあなたの健康ドシエをinouに追加しました。医療データを表示・管理できます。"
+invite_email_cta: "ログインして表示"
+continue: "続ける"
+
+# Access management
+people_with_access_count: "アクセス権のある人"
+view_audit_log: "監査ログを表示"
+export_data: "Download my data"
+relation_with: "との関係"
+audit_log: "監査ログ"
+audit_log_intro: "のアクティビティ履歴"
+audit_log_desc: "このドシエにアクセスまたは変更した人を追跡"
+
+# Install / Connect
+install_title: "Claudeに接続"
+install_intro: "inouブリッジを設定して、Claudeがあなたの健康データを分析できるようにします"
+install_step1: "ステップ1:ダウンロード"
+install_step1_desc: "お使いのプラットフォーム用のブリッジを取得"
+install_download_intro: "お使いのオペレーティングシステム用のinouブリッジをダウンロード:"
+install_step2: "ステップ2:設定"
+install_step2_desc: "Claude Desktopの設定に追加"
+install_config_intro: "これをClaude Desktopの設定ファイルに追加してください:"
+install_step3: "ステップ3:テスト"
+install_step3_desc: "接続を確認"
+install_test_intro: "Claude Desktopを再起動して、「inouプロフィールを表示」と尋ねてください"
+nav_install: "Claudeに接続"
+nav_home: "ホーム"
+
+# Status
+pending: "保留中"
+rate_limit_exceeded: "お住まいの地域からの登録試行が多すぎます。明日もう一度お試しください。"
+
+# Sex display
+sex_male: "男性"
+sex_female: "女性"
+sex_na: "その他"
+
+# Friend invite email
+friend_invite_subject: "これ見て — %s"
+friend_invite_p1: "inouを使っています。健康データを安全に保存してAIで分析できるサービスです。家族の健康情報をすべて一か所に保管できます — 画像検査、検査結果、医療記録など。あなたにも役立つかもしれないと思いました。"
+friend_invite_p2: "本当の力は、AIを使ってすべてを理解できることです:レポートが実際に何を意味するのか理解したり、時間の経過に伴う傾向を見つけたり、普通の言葉で質問して明確な回答を得たりできます。"
+friend_invite_btn: "inouを見る"
+friend_invite_dear: "%sさん、"
+rel_0: "あなた"
+rel_1: "親"
+rel_2: "子供"
+rel_3: "配偶者"
+rel_4: "兄弟姉妹"
+rel_5: "後見人"
+rel_6: "介護者"
+rel_7: "コーチ"
+rel_8: "医師"
+rel_9: "友人"
+rel_10: "その他"
+rel_99: "Demo"
+select_relation: "関係を選択..."
+
+# カテゴリー
+category000: 画像診断
+category001: 文書
+category002: 検査結果
+category003: ゲノム
+category004: アップロード
+category005: 相談
+category006: 診断
+category007: 画像検査結果
+category008: 脳波検査結果
+category009: バイタルサイン
+category010: 運動
+category011: 薬
+category012: サプリメント
+category013: 栄養
+category014: 妊娠・出産
+category015: 症状
+category016: メモ
+category017: 病歴
+category018: 家族歴
+category019: 手術
+category020: 入院
+category021: 出生データ
+category022: 医療機器
+category023: 治療
+category024: 評価
+category025: 医療提供者
+category026: 質問
+
+# Genome
+genome_english_only: "遺伝子情報はすべて英語です。Claudeを使って日本語で相談できます。"
+genome_variants: "バリアント"
+genome_hidden: "非表示"
+genome_show_all_categories: "全%dカテゴリを表示"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/ko.yaml b/lang/ko.yaml
new file mode 100644
index 0000000..d0677ac
--- /dev/null
+++ b/lang/ko.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "당신의 건강 데이터."
+headline_2: "당신의 AI."
+headline_3: "당신의 답변."
+intro: "영상, 검사 결과 등을 업로드하세요. AI를 연결하여 보고 있는 내용을 이해하는 데 도움을 받으세요."
+email: "이메일"
+get_started: "시작하기"
+data_yours: "당신의 데이터는 당신의 것"
+never_training: "훈련에 사용되지 않음"
+never_training_desc: "당신의 이미지는 AI 모델 훈련에 사용되지 않습니다."
+never_shared: "공유되지 않음"
+never_shared_desc: "당신의 데이터를 누구와도 공유하지 않습니다."
+encrypted: "암호화 저장"
+encrypted_desc: "모든 데이터가 저장 시 암호화됩니다."
+delete: "언제든 삭제 가능"
+delete_desc: "당신의 데이터, 당신의 통제."
+
+# Verify
+check_email: "이메일을 확인하세요"
+code_sent_to: "6자리 코드를 보냈습니다:"
+verification_code: "인증 코드"
+verify: "인증"
+use_different_email: "다른 이메일 사용"
+invalid_code: "잘못되었거나 만료된 코드입니다. 다시 시도하세요."
+
+# Onboard
+create_dossier: "서류철 만들기"
+create_profile_intro: "시작하려면 자신에 대해 알려주세요."
+name: "이름"
+name_placeholder: "이름"
+date_of_birth: "생년월일"
+sex_at_birth: "출생 시 성별"
+female: "여성"
+male: "남성"
+create_my_dossier: "내 서류철 만들기"
+
+# Minor error
+must_be_18: "계정을 만들려면 18세 이상이어야 합니다"
+minor_explanation: "다른 사람을 위해 설정하는 경우, 먼저 본인의 프로필부터 시작하세요. 이렇게 하면 본인만 그들의 건강 데이터에 접근할 수 있습니다."
+minor_next_steps: "서류철을 만든 후 다른 사람을 추가할 수 있습니다."
+use_different_dob: "다른 생년월일 사용"
+
+# Minor login block
+minor_login_blocked: "로그인하려면 18세 이상이어야 합니다"
+minor_ask_guardian: "%s에게 서류철 접근을 요청하세요."
+minor_ask_guardian_generic: "부모님이나 보호자에게 서류철 접근을 요청하세요."
+
+# Dashboard
+dossiers: "서류철"
+dossiers_intro: "본인 또는 다른 사람의 건강 데이터 관리"
+you: "나"
+view: "보기"
+save: "저장"
+cancel: "취소"
+add_dossier: "서류철 추가"
+edit_dossier: "서류철 편집"
+care: "돌봄"
+logout: "로그아웃"
+
+# Profile detail
+back_to_dossiers: "서류철로 돌아가기"
+born: "출생"
+no_access_yet: "본인만 접근할 수 있습니다."
+people_with_access: "접근 권한이 있는 사람"
+share_access: "접근 권한 공유"
+can_edit: "데이터 추가 가능"
+remove: "제거"
+confirm_revoke: "접근 권한을 제거하시겠습니까?"
+
+# Dossier sections
+section_imaging: "영상"
+section_labs: "검사"
+section_uploads: "업로드"
+section_vitals: "활력징후"
+section_medications: "약물"
+section_records: "기록"
+section_journal: "일지"
+section_genetics: "유전"
+section_privacy: "개인정보"
+
+# Section summaries
+imaging_summary: "%d건의 검사 · %d장의 슬라이스"
+no_imaging: "영상 데이터 없음"
+no_lab_data: "검사 데이터 없음"
+no_genetics: "유전 데이터 없음"
+no_files: "파일 없음"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d개 파일 (%s)"
+series_count: "%d개 시리즈"
+vitals_desc: "혈압, 심박수, 산소포화도, 체중, 혈당"
+medications_desc: "처방약 및 보충제"
+records_desc: "임상 기록 및 의무 기록"
+journal_desc: "증상, 통증 및 관찰"
+
+# Buttons and actions
+open_viewer: "뷰어 열기"
+manage: "관리"
+show_all_studies: "모든 %d건의 검사 보기..."
+coming_soon: "곧 출시"
+
+# Upload page
+upload_files: "건강 데이터 업로드"
+upload_files_intro: "의료 영상, 검사 결과, 유전체 파일 또는 건강 관련 문서를 업로드하세요."
+upload_hint_broad: "DICOM, PDF, CSV, VCF 등"
+uploading: "업로드 중..."
+files_uploaded: "파일이 업로드됨"
+upload_scans: "스캔 업로드"
+upload_scans_intro: "영상 검사의 DICOM 파일이 포함된 폴더를 업로드하세요."
+upload_drop: "클릭하거나 폴더를 여기로 드래그하세요"
+upload_hint: "DICOM 폴더만"
+
+# Add profile
+add_dossier_intro: "건강 데이터를 관리하고 싶은 사람을 추가하세요."
+email_optional: "이메일 (선택사항)"
+email_optional_hint: "18세 이상이면 직접 로그인할 수 있습니다"
+your_relation: "이 사람과의 관계"
+select_relation: "선택..."
+i_provide_care: "이 사람을 돌봅니다"
+i_am_their: "나는 그들의..."
+
+# Share access
+share_access_intro: "접근할 사람 초대"
+their_relation: "이 사람과의 관계"
+can_add_data: "데이터 추가 가능 (보충제, 메모 등)"
+send_invitation: "초대 보내기"
+back_to_dossier: "서류철로 돌아가기"
+
+# Relations
+my_role: "내 역할"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s님이 inou에 당신을 추가했습니다"
+invite_email_body: "%s님이 당신의 건강 서류철을 inou에 추가하여 의료 데이터를 보고 관리할 수 있습니다."
+invite_email_cta: "로그인하여 보기"
+continue: "계속"
+
+# Access management
+people_with_access_count: "접근 권한이 있는 사람"
+view_audit_log: "감사 로그 보기"
+export_data: "Download my data"
+relation_with: "와의 관계"
+audit_log: "감사 로그"
+audit_log_intro: "활동 기록"
+audit_log_desc: "이 서류철에 접근하거나 수정한 사람 추적"
+
+# Install / Connect
+install_title: "Claude에 연결"
+install_intro: "inou 브릿지를 설정하여 Claude가 건강 데이터를 분석할 수 있게 합니다"
+install_step1: "1단계: 다운로드"
+install_step1_desc: "플랫폼용 브릿지 받기"
+install_download_intro: "운영 체제용 inou 브릿지를 다운로드하세요:"
+install_step2: "2단계: 구성"
+install_step2_desc: "Claude Desktop 설정에 추가"
+install_config_intro: "Claude Desktop 설정 파일에 다음을 추가하세요:"
+install_step3: "3단계: 테스트"
+install_step3_desc: "연결 확인"
+install_test_intro: "Claude Desktop을 재시작하고 'inou 프로필 보여줘'라고 물어보세요"
+nav_install: "Claude에 연결"
+nav_home: "홈"
+
+# Status
+pending: "대기 중"
+rate_limit_exceeded: "현재 위치에서 가입 시도가 너무 많습니다. 내일 다시 시도하세요."
+
+# Sex display
+sex_male: "남성"
+sex_female: "여성"
+sex_na: "기타"
+
+# Friend invite email
+friend_invite_subject: "이것 좀 봐 — %s"
+friend_invite_p1: "inou를 사용하고 있어요. 건강 데이터를 안전하게 저장하고 AI로 분석할 수 있는 서비스예요. 우리 가족의 모든 건강 정보를 한 곳에 보관할 수 있어요 — 영상 검사, 검사 결과, 의료 기록 등. 당신에게도 유용할 것 같아서 알려드려요."
+friend_invite_p2: "진정한 힘은 AI를 사용해서 모든 것을 이해할 수 있다는 거예요: 보고서가 실제로 무엇을 의미하는지 이해하고, 시간에 따른 추세를 파악하거나, 일상적인 언어로 질문하고 명확한 답변을 얻을 수 있어요."
+friend_invite_btn: "inou 알아보기"
+friend_invite_dear: "%s님,"
+rel_0: "당신"
+rel_1: "부모"
+rel_2: "자녀"
+rel_3: "배우자"
+rel_4: "형제자매"
+rel_5: "후견인"
+rel_6: "간병인"
+rel_7: "코치"
+rel_8: "의사"
+rel_9: "친구"
+rel_10: "기타"
+rel_99: "Demo"
+select_relation: "관계 선택..."
+
+# 카테고리
+category000: 영상
+category001: 문서
+category002: 검사 결과
+category003: 게놈
+category004: 업로드
+category005: 상담
+category006: 진단
+category007: 영상 검사 결과
+category008: 뇌파 검사 결과
+category009: 활력 징후
+category010: 운동
+category011: 약물
+category012: 보충제
+category013: 영양
+category014: 생식력
+category015: 증상
+category016: 메모
+category017: 병력
+category018: 가족력
+category019: 수술
+category020: 입원
+category021: 출생 데이터
+category022: 의료 기기
+category023: 치료
+category024: 평가
+category025: 의료 제공자
+category026: 질문
+
+# Genome
+genome_english_only: "모든 유전자 정보는 영어로 되어 있습니다. Claude를 사용하여 한국어로 상담하세요."
+genome_variants: "변이"
+genome_hidden: "숨김"
+genome_show_all_categories: "전체 %d개 카테고리 표시"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/nl.yaml b/lang/nl.yaml
new file mode 100644
index 0000000..933081a
--- /dev/null
+++ b/lang/nl.yaml
@@ -0,0 +1,240 @@
+# Landing
+headline_1: "Jouw gezondheidsdata."
+headline_2: "Jouw AI."
+headline_3: "Jouw antwoorden."
+intro: "Upload beeldvorming, labresultaten en meer. Verbind je AI om te begrijpen wat je ziet."
+email: "E-mail"
+get_started: "Aan de slag"
+data_yours: "Jouw data blijft van jou"
+never_training: "Nooit gebruikt voor training"
+never_training_desc: "Je beelden worden nooit gebruikt om AI-modellen te trainen."
+never_shared: "Nooit gedeeld"
+never_shared_desc: "We delen je data nooit met anderen."
+encrypted: "Versleutelde opslag"
+encrypted_desc: "Alle data versleuteld opgeslagen."
+delete: "Altijd verwijderen"
+delete_desc: "Jouw data, jouw controle."
+
+# Verify
+check_email: "Controleer je e-mail"
+code_sent_to: "We hebben een 6-cijferige code gestuurd naar"
+verification_code: "Verificatiecode"
+verify: "Verifiëren"
+use_different_email: "Ander e-mailadres gebruiken"
+invalid_code: "Ongeldige of verlopen code. Probeer opnieuw."
+
+# Onboard
+create_dossier: "Maak je dossier aan"
+create_profile_intro: "Vertel ons over jezelf om te beginnen."
+name: "Naam"
+name_placeholder: "Je naam"
+date_of_birth: "Geboortedatum"
+sex_at_birth: "Geslacht bij geboorte"
+female: "Vrouw"
+male: "Man"
+create_my_dossier: "Mijn dossier aanmaken"
+
+# Minor error
+must_be_18: "Je moet 18 zijn om een account aan te maken"
+minor_explanation: "Als je dit voor iemand anders instelt, begin dan eerst met je eigen profiel. Zo heb alleen jij toegang tot hun gezondheidsgegevens."
+minor_next_steps: "Na het aanmaken van je dossier kun je anderen toevoegen."
+use_different_dob: "Andere geboortedatum gebruiken"
+
+# Minor login block
+minor_login_blocked: "Je moet 18 zijn om in te loggen"
+minor_ask_guardian: "Vraag %s om toegang tot je dossier."
+minor_ask_guardian_generic: "Vraag een ouder of voogd om toegang tot je dossier."
+
+# Dashboard
+dossiers: "Dossiers"
+dossiers_intro: "Beheer de gezondheidsgegevens van jezelf of voor anderen"
+you: "jij"
+view: "Bekijken"
+save: "Opslaan"
+cancel: "Annuleren"
+add_dossier: "Dossier toevoegen"
+edit_dossier: "Dossier bewerken"
+care: "zorg"
+logout: "Uitloggen"
+
+# Profile detail
+back_to_dossiers: "Terug naar dossiers"
+born: "Geboren"
+no_access_yet: "Alleen jij hebt toegang."
+people_with_access: "Personen met toegang"
+share_access: "Toegang delen"
+can_edit: "kan gegevens toevoegen"
+remove: "Verwijderen"
+confirm_revoke: "Toegang intrekken?"
+
+# Dossier sections
+section_imaging: "Radiologie"
+section_labs: "Labresultaten"
+section_uploads: "Uploads"
+section_vitals: "Vitale functies"
+section_medications: "Medicatie"
+section_records: "Dossiers"
+section_journal: "Dagboek"
+
+# Section summaries
+imaging_summary: "%d onderzoeken · %d beelden"
+no_imaging: "Geen beeldvorming"
+no_lab_data: "Geen labresultaten"
+no_files: "Geen bestanden"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d bestanden (%s)"
+series_count: "%d series"
+vitals_desc: "Bloeddruk, hartslag, SpO₂, gewicht, glucose"
+medications_desc: "Recepten en supplementen"
+records_desc: "Klinische notities en medische dossiers"
+journal_desc: "Symptomen, pijn en observaties"
+
+# Buttons and actions
+open_viewer: "Viewer openen"
+manage: "Beheren"
+show_all_studies: "Toon alle %d onderzoeken..."
+coming_soon: "Binnenkort beschikbaar"
+
+# Upload page
+upload_files: "Gezondheidsgegevens uploaden"
+upload_files_intro: "Upload medische beeldvorming, labresultaten, genoombestanden of andere gezondheidsgerelateerde documenten."
+upload_hint_broad: "DICOM, PDF, CSV, VCF en meer"
+uploading: "Uploaden..."
+files_uploaded: "bestanden geüpload"
+upload_scans: "Scans uploaden"
+upload_scans_intro: "Upload een map met DICOM-bestanden van je beeldvormend onderzoek."
+upload_drop: "Klik of sleep een map hierheen"
+upload_hint: "Alleen DICOM-mappen"
+
+# Add profile
+add_dossier_intro: "Voeg iemand toe wiens gezondheidsgegevens je wilt beheren."
+email_optional: "E-mail (optioneel)"
+email_optional_hint: "Als ze 18+ zijn, kunnen ze zelf inloggen"
+your_relation: "Jouw relatie met hen"
+select_relation: "Selecteer..."
+i_provide_care: "Ik zorg voor deze persoon"
+
+# Share access
+share_access_intro: "Nodig iemand uit voor toegang tot"
+their_relation: "Hun relatie met deze persoon"
+can_add_data: "Kan gegevens toevoegen (supplementen, notities, etc.)"
+send_invitation: "Uitnodiging versturen"
+back_to_dossier: "Terug naar dossier"
+
+# Relations
+
+# Invitation email
+invite_email_subject: "%s heeft je toegevoegd aan inou"
+invite_email_body: "%s heeft je gezondheidsdossier toegevoegd aan inou zodat je je medische gegevens kunt bekijken en beheren."
+invite_email_cta: "Inloggen om te bekijken"
+continue: "Doorgaan"
+i_am_their: "Ik ben hun..."
+
+# Simple relation names (for display)
+my_role: "mijn rol"
+role: "role"
+section_privacy: "Privacy"
+people_with_access_count: "personen met toegang"
+view_audit_log: "Bekijk auditlog"
+export_data: "Download my data"
+relation_with: "Relatie met"
+audit_log: "Auditlog"
+audit_log_intro: "Activiteitengeschiedenis voor"
+audit_log_desc: "Bekijk wie dit dossier heeft bekeken of gewijzigd"
+install_title: "Verbind met Claude"
+install_intro: "Stel de inou-bridge in zodat Claude je gezondheidsgegevens kan analyseren"
+install_step1: "Stap 1: Download"
+install_step1_desc: "Download de bridge voor jouw platform"
+install_download_intro: "Download de inou-bridge voor jouw besturingssysteem:"
+install_step2: "Stap 2: Configureer"
+install_step2_desc: "Voeg toe aan Claude Desktop configuratie"
+install_config_intro: "Voeg dit toe aan je Claude Desktop configuratiebestand:"
+install_step3: "Stap 3: Test"
+install_step3_desc: "Controleer de verbinding"
+install_test_intro: "Herstart Claude Desktop en vraag: 'Toon mijn inou profielen'"
+nav_install: "Verbind met Claude"
+nav_home: "Home"
+pending: "in afwachting"
+rate_limit_exceeded: "Te veel aanmeldpogingen vanaf uw locatie. Probeer het morgen opnieuw."
+section_genetics: Genetica
+no_genetics: Geen genetische gegevens
+
+sex_male: "mannelijk"
+sex_female: "vrouwelijk"
+sex_na: "anders"
+
+# Friend invite email
+friend_invite_subject: "Kijk hier eens naar — %s"
+friend_invite_p1: "Ik gebruik inou, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. Het houdt alle gezondheidsinformatie van mijn familie op één plek — beeldvorming, labresultaten, medische dossiers — en ik dacht dat jij het misschien ook handig zou vinden."
+friend_invite_p2: "De echte kracht is dat je AI kunt gebruiken om alles te begrijpen: begrijpen wat een rapport echt betekent, trends in de tijd ontdekken, of gewoon vragen stellen in gewone taal en duidelijke antwoorden krijgen."
+friend_invite_btn: "Bekijk inou"
+friend_invite_dear: "Beste %s,"
+rel_0: "jij"
+rel_1: "Ouder"
+rel_2: "Kind"
+rel_3: "Partner"
+rel_4: "Broer/Zus"
+rel_5: "Voogd"
+rel_6: "Verzorger"
+rel_7: "Coach"
+rel_8: "Arts"
+rel_9: "Vriend"
+rel_10: "Anders"
+rel_99: "Demo"
+select_relation: "Selecteer relatie..."
+audit_dossier_added: "Nieuw dossier voor %s aangemaakt door %s"
+audit_dossier_edited: "Dossier %s bewerkt door %s"
+audit_access_granted: "Toegang tot %s verleend aan %s"
+audit_dossier_created: Account aangemaakt door %s
+audit_access_revoked: Toegang voor %s tot %s ingetrokken
+audit_file_upload: Bestand %s geüpload door %s
+audit_file_delete: Bestand %s verwijderd door %s
+audit_file_category_change: Bestandscategorie %s gewijzigd door %s
+audit_genome_import: %s genetische varianten geïmporteerd
+
+# Categorieën
+category000: Beeldvorming
+category001: Document
+category002: Labuitslag
+category003: Genoom
+category004: Upload
+category005: Consult
+category006: Diagnose
+category007: Beeldvormingsresultaat
+category008: EEG-resultaat
+category009: Vitale waarde
+category010: Beweging
+category011: Medicatie
+category012: Supplement
+category013: Voeding
+category014: Vruchtbaarheid
+category015: Symptoom
+category016: Notitie
+category017: Medische geschiedenis
+category018: Familiegeschiedenis
+category019: Operatie
+category020: Ziekenhuisopname
+category021: Geboortegegevens
+category022: Medisch hulpmiddel
+category023: Therapie
+category024: Beoordeling
+category025: Zorgverlener
+category026: Vraag
+
+# Genome
+genome_english_only: "Alle genetische informatie is in het Engels. Gebruik Claude om het in het Nederlands te bespreken."
+genome_variants: "varianten"
+genome_hidden: "verborgen"
+genome_show_all_categories: "Toon alle %d categorieën"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/no.yaml b/lang/no.yaml
new file mode 100644
index 0000000..c1eb88a
--- /dev/null
+++ b/lang/no.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Dine helsedata."
+headline_2: "Din AI."
+headline_3: "Dine svar."
+intro: "Last opp bildediagnostikk, labresultater og mer. Koble til din AI for å hjelpe deg forstå hva du ser på."
+email: "E-post"
+get_started: "Kom i gang"
+data_yours: "Dine data forblir dine"
+never_training: "Brukes aldri til trening"
+never_training_desc: "Bildene dine brukes aldri til å trene AI-modeller."
+never_shared: "Deles aldri"
+never_shared_desc: "Vi deler aldri dataene dine med noen."
+encrypted: "Kryptert lagring"
+encrypted_desc: "Alle data kryptert i hvile."
+delete: "Slett når som helst"
+delete_desc: "Dine data, din kontroll."
+
+# Verify
+check_email: "Sjekk e-posten din"
+code_sent_to: "Vi sendte en 6-sifret kode til"
+verification_code: "Verifiseringskode"
+verify: "Verifiser"
+use_different_email: "Bruk en annen e-post"
+invalid_code: "Ugyldig eller utløpt kode. Prøv igjen."
+
+# Onboard
+create_dossier: "Opprett din dosje"
+create_profile_intro: "Fortell oss om deg selv for å komme i gang."
+name: "Navn"
+name_placeholder: "Ditt navn"
+date_of_birth: "Fødselsdato"
+sex_at_birth: "Kjønn ved fødsel"
+female: "Kvinne"
+male: "Mann"
+create_my_dossier: "Opprett min dosje"
+
+# Minor error
+must_be_18: "Du må være 18 for å opprette en konto"
+minor_explanation: "Hvis du setter dette opp for noen andre, start med din egen profil først. Dette sikrer at bare du kan få tilgang til deres helsedata."
+minor_next_steps: "Etter at du har opprettet din dosje, kan du legge til andre."
+use_different_dob: "Bruk en annen fødselsdato"
+
+# Minor login block
+minor_login_blocked: "Du må være 18 for å logge inn"
+minor_ask_guardian: "Be %s om tilgang til din dosje."
+minor_ask_guardian_generic: "Be en forelder eller foresatt om tilgang til din dosje."
+
+# Dashboard
+dossiers: "Dosjer"
+dossiers_intro: "Administrer helsedata for deg selv eller andre"
+you: "deg"
+view: "Vis"
+save: "Lagre"
+cancel: "Avbryt"
+add_dossier: "Legg til dosje"
+edit_dossier: "Rediger dosje"
+care: "omsorg"
+logout: "Logg ut"
+
+# Profile detail
+back_to_dossiers: "Tilbake til dosjer"
+born: "Født"
+no_access_yet: "Bare du har tilgang."
+people_with_access: "Personer med tilgang"
+share_access: "Del tilgang"
+can_edit: "kan legge til data"
+remove: "Fjern"
+confirm_revoke: "Fjerne tilgang?"
+
+# Dossier sections
+section_imaging: "Bildediagnostikk"
+section_labs: "Lab"
+section_uploads: "Opplastinger"
+section_vitals: "Vitaltegn"
+section_medications: "Medisiner"
+section_records: "Journaler"
+section_journal: "Dagbok"
+section_genetics: "Genetikk"
+section_privacy: "Personvern"
+
+# Section summaries
+imaging_summary: "%d undersøkelser · %d snitt"
+no_imaging: "Ingen bildedata"
+no_lab_data: "Ingen labdata"
+no_genetics: "Ingen genetiske data"
+no_files: "Ingen filer"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d filer (%s)"
+series_count: "%d serier"
+vitals_desc: "Blodtrykk, puls, SpO₂, vekt, blodsukker"
+medications_desc: "Resepter og kosttilskudd"
+records_desc: "Kliniske notater og journaler"
+journal_desc: "Symptomer, smerte og observasjoner"
+
+# Buttons and actions
+open_viewer: "Åpne visning"
+manage: "Administrer"
+show_all_studies: "Vis alle %d undersøkelser..."
+coming_soon: "Kommer snart"
+
+# Upload page
+upload_files: "Last opp helsedata"
+upload_files_intro: "Last opp medisinsk bildediagnostikk, labresultater, genomfiler eller helserelaterte dokumenter."
+upload_hint_broad: "DICOM, PDF, CSV, VCF og mer"
+uploading: "Laster opp..."
+files_uploaded: "filer lastet opp"
+upload_scans: "Last opp skanninger"
+upload_scans_intro: "Last opp en mappe med DICOM-filer fra din bildeundersøkelse."
+upload_drop: "Klikk eller dra en mappe hit"
+upload_hint: "Kun DICOM-mapper"
+
+# Add profile
+add_dossier_intro: "Legg til noen hvis helsedata du vil administrere."
+email_optional: "E-post (valgfritt)"
+email_optional_hint: "Hvis de er 18, kan de logge inn selv"
+your_relation: "Ditt forhold til dem"
+select_relation: "Velg..."
+i_provide_care: "Jeg gir omsorg til denne personen"
+i_am_their: "Jeg er deres..."
+
+# Share access
+share_access_intro: "Inviter noen til å få tilgang"
+their_relation: "Deres forhold til denne personen"
+can_add_data: "Kan legge til data (kosttilskudd, notater, etc.)"
+send_invitation: "Send invitasjon"
+back_to_dossier: "Tilbake til dosje"
+
+# Relations
+my_role: "min rolle"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s la deg til i inou"
+invite_email_body: "%s la til din helsedosje i inou slik at du kan se og administrere dine medisinske data."
+invite_email_cta: "Logg inn for å se"
+continue: "Fortsett"
+
+# Access management
+people_with_access_count: "personer med tilgang"
+view_audit_log: "Vis aktivitetslogg"
+export_data: "Download my data"
+relation_with: "Forhold til"
+audit_log: "Aktivitetslogg"
+audit_log_intro: "Aktivitetshistorikk for"
+audit_log_desc: "Spor hvem som har hatt tilgang til eller endret denne dosjen"
+
+# Install / Connect
+install_title: "Koble til Claude"
+install_intro: "Sett opp inou-broen for å la Claude analysere helsedataene dine"
+install_step1: "Steg 1: Last ned"
+install_step1_desc: "Hent broen for din plattform"
+install_download_intro: "Last ned inou-broen for ditt operativsystem:"
+install_step2: "Steg 2: Konfigurer"
+install_step2_desc: "Legg til i Claude Desktop-konfigurasjonen"
+install_config_intro: "Legg til dette i Claude Desktop-konfigurasjonsfilen din:"
+install_step3: "Steg 3: Test"
+install_step3_desc: "Verifiser tilkoblingen"
+install_test_intro: "Start Claude Desktop på nytt og spør: 'Vis meg mine inou-profiler'"
+nav_install: "Koble til Claude"
+nav_home: "Hjem"
+
+# Status
+pending: "venter"
+rate_limit_exceeded: "For mange registreringsforsøk fra din lokasjon. Prøv igjen i morgen."
+
+# Sex display
+sex_male: "mann"
+sex_female: "kvinne"
+sex_na: "annet"
+
+# Friend invite email
+friend_invite_subject: "Sjekk dette — %s"
+friend_invite_p1: "Jeg bruker inou, den sikre måten å lagre helsedata og utforske dem med AI. Det holder all helseinformasjonen til familien min på ett sted — bildestudier, labresultater, journaler — og jeg tenkte det kanskje kunne være nyttig for deg også."
+friend_invite_p2: "Den virkelige kraften ligger i å kunne bruke AI til å forstå alt: forstå hva en rapport faktisk betyr, oppdage trender over tid, eller bare stille spørsmål på vanlig norsk og få klare svar."
+friend_invite_btn: "Oppdag inou"
+friend_invite_dear: "Hei %s,"
+rel_0: "du"
+rel_1: "Forelder"
+rel_2: "Barn"
+rel_3: "Ektefelle"
+rel_4: "Søsken"
+rel_5: "Verge"
+rel_6: "Omsorgsgiver"
+rel_7: "Coach"
+rel_8: "Lege"
+rel_9: "Venn"
+rel_10: "Annet"
+rel_99: "Demo"
+select_relation: "Velg relasjon..."
+
+# Kategorier
+category000: Bildediagnostikk
+category001: Dokument
+category002: Labresultat
+category003: Genom
+category004: Opplasting
+category005: Konsultasjon
+category006: Diagnose
+category007: Bilderesultat
+category008: EEG-resultat
+category009: Vitalverdi
+category010: Trening
+category011: Medisin
+category012: Tilskudd
+category013: Ernæring
+category014: Fertilitet
+category015: Symptom
+category016: Notat
+category017: Sykehistorie
+category018: Familiehistorie
+category019: Kirurgi
+category020: Sykehusopphold
+category021: Fødselsdata
+category022: Medisinsk utstyr
+category023: Terapi
+category024: Vurdering
+category025: Helsepersonell
+category026: Spørsmål
+
+# Genome
+genome_english_only: "All genetisk informasjon er på engelsk. Bruk Claude for å diskutere det på norsk."
+genome_variants: "varianter"
+genome_hidden: "skjult"
+genome_show_all_categories: "Vis alle %d kategorier"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/pt.yaml b/lang/pt.yaml
new file mode 100644
index 0000000..2417885
--- /dev/null
+++ b/lang/pt.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Seus dados de saúde."
+headline_2: "Sua IA."
+headline_3: "Suas respostas."
+intro: "Carregue exames de imagem, laboratórios e mais. Conecte sua IA para ajudar a entender o que você está vendo."
+email: "E-mail"
+get_started: "Começar"
+data_yours: "Seus dados permanecem seus"
+never_training: "Nunca usado para treinamento"
+never_training_desc: "Suas imagens nunca são usadas para treinar modelos de IA."
+never_shared: "Nunca compartilhado"
+never_shared_desc: "Nunca compartilhamos seus dados com ninguém."
+encrypted: "Armazenamento criptografado"
+encrypted_desc: "Todos os dados criptografados em repouso."
+delete: "Exclua a qualquer momento"
+delete_desc: "Seus dados, seu controle."
+
+# Verify
+check_email: "Verifique seu e-mail"
+code_sent_to: "Enviamos um código de 6 dígitos para"
+verification_code: "Código de verificação"
+verify: "Verificar"
+use_different_email: "Usar outro e-mail"
+invalid_code: "Código inválido ou expirado. Tente novamente."
+
+# Onboard
+create_dossier: "Crie seu dossiê"
+create_profile_intro: "Conte-nos sobre você para começar."
+name: "Nome"
+name_placeholder: "Seu nome"
+date_of_birth: "Data de nascimento"
+sex_at_birth: "Sexo ao nascer"
+female: "Feminino"
+male: "Masculino"
+create_my_dossier: "Criar meu dossiê"
+
+# Minor error
+must_be_18: "Você deve ter 18 anos para criar uma conta"
+minor_explanation: "Se você está configurando isso para outra pessoa, comece com seu próprio perfil primeiro. Isso garante que só você possa acessar os dados de saúde dela."
+minor_next_steps: "Após criar seu dossiê, você pode adicionar outros."
+use_different_dob: "Usar outra data de nascimento"
+
+# Minor login block
+minor_login_blocked: "Você deve ter 18 anos para entrar"
+minor_ask_guardian: "Peça a %s para acessar seu dossiê."
+minor_ask_guardian_generic: "Peça a um pai ou responsável para acessar seu dossiê."
+
+# Dashboard
+dossiers: "Dossiês"
+dossiers_intro: "Gerencie dados de saúde para você ou outros"
+you: "você"
+view: "Ver"
+save: "Salvar"
+cancel: "Cancelar"
+add_dossier: "Adicionar dossiê"
+edit_dossier: "Editar dossiê"
+care: "cuidado"
+logout: "Sair"
+
+# Profile detail
+back_to_dossiers: "Voltar aos dossiês"
+born: "Nascido"
+no_access_yet: "Apenas você tem acesso."
+people_with_access: "Pessoas com acesso"
+share_access: "Compartilhar acesso"
+can_edit: "pode adicionar dados"
+remove: "Remover"
+confirm_revoke: "Remover acesso?"
+
+# Dossier sections
+section_imaging: "Imagens"
+section_labs: "Laboratório"
+section_uploads: "Arquivos"
+section_vitals: "Sinais vitais"
+section_medications: "Medicamentos"
+section_records: "Prontuários"
+section_journal: "Diário"
+section_genetics: "Genética"
+section_privacy: "Privacidade"
+
+# Section summaries
+imaging_summary: "%d estudos · %d cortes"
+no_imaging: "Sem dados de imagem"
+no_lab_data: "Sem dados de laboratório"
+no_genetics: "Sem dados genéticos"
+no_files: "Sem arquivos"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d arquivos (%s)"
+series_count: "%d séries"
+vitals_desc: "Pressão arterial, frequência cardíaca, SpO₂, peso, glicose"
+medications_desc: "Prescrições e suplementos"
+records_desc: "Notas clínicas e prontuários médicos"
+journal_desc: "Sintomas, dor e observações"
+
+# Buttons and actions
+open_viewer: "Abrir visualizador"
+manage: "Gerenciar"
+show_all_studies: "Mostrar todos os %d estudos..."
+coming_soon: "Em breve"
+
+# Upload page
+upload_files: "Carregar dados de saúde"
+upload_files_intro: "Carregue imagens médicas, resultados de laboratório, arquivos genômicos ou quaisquer documentos de saúde."
+upload_hint_broad: "DICOM, PDF, CSV, VCF e mais"
+uploading: "Carregando..."
+files_uploaded: "arquivos carregados"
+upload_scans: "Carregar exames"
+upload_scans_intro: "Carregue uma pasta contendo arquivos DICOM do seu estudo de imagem."
+upload_drop: "Clique ou arraste uma pasta aqui"
+upload_hint: "Apenas pastas DICOM"
+
+# Add profile
+add_dossier_intro: "Adicione alguém cujos dados de saúde você deseja gerenciar."
+email_optional: "E-mail (opcional)"
+email_optional_hint: "Se tiver 18 anos, pode entrar sozinho"
+your_relation: "Seu parentesco com esta pessoa"
+select_relation: "Selecione..."
+i_provide_care: "Eu cuido desta pessoa"
+i_am_their: "Eu sou..."
+
+# Share access
+share_access_intro: "Convidar alguém para acessar"
+their_relation: "Parentesco desta pessoa com o dossiê"
+can_add_data: "Pode adicionar dados (suplementos, notas, etc.)"
+send_invitation: "Enviar convite"
+back_to_dossier: "Voltar ao dossiê"
+
+# Relations
+my_role: "meu papel"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s adicionou você ao inou"
+invite_email_body: "%s adicionou seu dossiê de saúde ao inou para que você possa visualizar e gerenciar seus dados médicos."
+invite_email_cta: "Entrar para ver"
+continue: "Continuar"
+
+# Access management
+people_with_access_count: "pessoas com acesso"
+view_audit_log: "Ver registro de atividades"
+export_data: "Download my data"
+relation_with: "Relação com"
+audit_log: "Registro de atividades"
+audit_log_intro: "Histórico de atividades para"
+audit_log_desc: "Acompanhe quem acessou ou modificou este dossiê"
+
+# Install / Connect
+install_title: "Conectar ao Claude"
+install_intro: "Configure a ponte inou para permitir que o Claude analise seus dados de saúde"
+install_step1: "Passo 1: Baixar"
+install_step1_desc: "Obtenha a ponte para sua plataforma"
+install_download_intro: "Baixe a ponte inou para seu sistema operacional:"
+install_step2: "Passo 2: Configurar"
+install_step2_desc: "Adicione à configuração do Claude Desktop"
+install_config_intro: "Adicione isso ao seu arquivo de configuração do Claude Desktop:"
+install_step3: "Passo 3: Testar"
+install_step3_desc: "Verifique a conexão"
+install_test_intro: "Reinicie o Claude Desktop e pergunte: 'Mostre meus perfis inou'"
+nav_install: "Conectar ao Claude"
+nav_home: "Início"
+
+# Status
+pending: "pendente"
+rate_limit_exceeded: "Muitas tentativas de cadastro da sua localização. Tente novamente amanhã."
+
+# Sex display
+sex_male: "masculino"
+sex_female: "feminino"
+sex_na: "outro"
+
+# Friend invite email
+friend_invite_subject: "Dá uma olhada — %s"
+friend_invite_p1: "Tenho usado o inou, a forma segura de armazenar dados de saúde e explorá-los com IA. Ele mantém todas as informações de saúde da minha família num só lugar — exames de imagem, resultados de laboratório, prontuários médicos — e achei que também poderia ser útil para você."
+friend_invite_p2: "O verdadeiro poder está em usar IA para entender tudo: compreender o que um relatório realmente significa, identificar tendências ao longo do tempo, ou simplesmente fazer perguntas em linguagem simples e obter respostas claras."
+friend_invite_btn: "Conhecer o inou"
+friend_invite_dear: "Olá %s,"
+rel_0: "você"
+rel_1: "Pai/Mãe"
+rel_2: "Filho/a"
+rel_3: "Cônjuge"
+rel_4: "Irmão/ã"
+rel_5: "Tutor"
+rel_6: "Cuidador"
+rel_7: "Coach"
+rel_8: "Médico"
+rel_9: "Amigo"
+rel_10: "Outro"
+rel_99: "Demo"
+select_relation: "Selecionar relação..."
+
+# Categorias
+category000: Imagem médica
+category001: Documento
+category002: Resultado de laboratório
+category003: Genoma
+category004: Upload
+category005: Consulta
+category006: Diagnóstico
+category007: Resultado de imagem
+category008: Resultado de EEG
+category009: Sinal vital
+category010: Exercício
+category011: Medicamento
+category012: Suplemento
+category013: Nutrição
+category014: Fertilidade
+category015: Sintoma
+category016: Nota
+category017: Histórico médico
+category018: Histórico familiar
+category019: Cirurgia
+category020: Hospitalização
+category021: Dados de nascimento
+category022: Dispositivo médico
+category023: Terapia
+category024: Avaliação
+category025: Prestador de saúde
+category026: Pergunta
+
+# Genome
+genome_english_only: "Todas as informações genéticas estão em inglês. Use o Claude para discuti-las em português."
+genome_variants: "variantes"
+genome_hidden: "ocultas"
+genome_show_all_categories: "Mostrar todas as %d categorias"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/ru.yaml b/lang/ru.yaml
new file mode 100644
index 0000000..eb32697
--- /dev/null
+++ b/lang/ru.yaml
@@ -0,0 +1,226 @@
+# Landing
+headline_1: "Ваши медицинские данные."
+headline_2: "Ваш ИИ."
+headline_3: "Ваши ответы."
+intro: "Загружайте снимки, анализы и многое другое. Подключите ИИ, чтобы понять, что вы видите."
+email: "Электронная почта"
+get_started: "Начать"
+data_yours: "Ваши данные остаются вашими"
+never_training: "Никогда не используются для обучения"
+never_training_desc: "Ваши снимки никогда не используются для обучения моделей ИИ."
+never_shared: "Никогда не передаются"
+never_shared_desc: "Мы никогда не делимся вашими данными."
+encrypted: "Зашифрованное хранилище"
+encrypted_desc: "Все данные зашифрованы."
+delete: "Удалить в любое время"
+delete_desc: "Ваши данные, ваш контроль."
+
+# Verify
+check_email: "Проверьте почту"
+code_sent_to: "Мы отправили 6-значный код на"
+verification_code: "Код подтверждения"
+verify: "Подтвердить"
+use_different_email: "Использовать другой адрес"
+invalid_code: "Неверный или просроченный код. Попробуйте снова."
+
+# Onboard
+create_dossier: "Создайте досье"
+create_profile_intro: "Расскажите о себе, чтобы начать."
+name: "Имя"
+name_placeholder: "Ваше имя"
+date_of_birth: "Дата рождения"
+sex_at_birth: "Пол при рождении"
+female: "Женский"
+male: "Мужской"
+create_my_dossier: "Создать моё досье"
+
+# Minor error
+must_be_18: "Вам должно быть 18 лет для создания аккаунта"
+minor_explanation: "Если вы настраиваете это для другого человека, сначала создайте свой профиль. Это гарантирует, что только вы сможете получить доступ к их медицинским данным."
+minor_next_steps: "После создания досье вы сможете добавить других."
+use_different_dob: "Использовать другую дату рождения"
+
+# Minor login block
+minor_login_blocked: "Вам должно быть 18 лет для входа"
+minor_ask_guardian: "Попросите %s открыть доступ к вашему досье."
+minor_ask_guardian_generic: "Попросите родителя или опекуна открыть доступ к вашему досье."
+
+# Dashboard
+dossiers: "Досье"
+dossiers_intro: "Управляйте медицинскими данными для себя или других"
+you: "вы"
+view: "Просмотр"
+add_dossier: "Добавить досье"
+edit_dossier: "Редактировать досье"
+care: "забота"
+logout: "Выйти"
+
+# Profile detail
+back_to_dossiers: "Назад к досье"
+born: "Родился"
+no_access_yet: "Только у вас есть доступ."
+people_with_access: "Люди с доступом"
+share_access: "Поделиться доступом"
+can_edit: "может добавлять данные"
+remove: "Удалить"
+confirm_revoke: "Отозвать доступ?"
+
+# Dossier sections
+section_imaging: "Снимки"
+section_labs: "Анализы"
+section_uploads: "Загрузки"
+section_vitals: "Показатели"
+section_medications: "Лекарства"
+section_records: "Записи"
+section_journal: "Дневник"
+
+# Section summaries
+imaging_summary: "%d исследований · %d снимков"
+no_imaging: "Нет снимков"
+no_lab_data: "Нет анализов"
+no_files: "Нет файлов"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d файлов (%s)"
+series_count: "%d серий"
+vitals_desc: "Давление, пульс, SpO₂, вес, глюкоза"
+medications_desc: "Рецепты и добавки"
+records_desc: "Клинические записи и медицинские документы"
+journal_desc: "Симптомы, боль и наблюдения"
+
+# Buttons and actions
+open_viewer: "Открыть просмотрщик"
+manage: "Управление"
+show_all_studies: "Показать все %d исследований..."
+coming_soon: "Скоро"
+
+# Upload page
+upload_files: "Загрузить медицинские данные"
+upload_files_intro: "Загружайте медицинские снимки, анализы, геномные файлы или другие документы о здоровье."
+upload_hint_broad: "DICOM, PDF, CSV, VCF и другие"
+uploading: "Загрузка..."
+files_uploaded: "файлов загружено"
+upload_scans: "Загрузить снимки"
+upload_scans_intro: "Загрузите папку с DICOM-файлами вашего исследования."
+upload_drop: "Нажмите или перетащите папку сюда"
+upload_hint: "Только папки DICOM"
+
+# Add profile
+add_dossier_intro: "Добавьте человека, чьими медицинскими данными хотите управлять."
+email_optional: "Email (необязательно)"
+email_optional_hint: "Если им 18+, они смогут войти сами"
+your_relation: "Ваши отношения с ними"
+select_relation: "Выберите..."
+i_provide_care: "Я забочусь об этом человеке"
+
+# Share access
+share_access_intro: "Пригласите кого-то для доступа к"
+their_relation: "Их отношения с этим человеком"
+can_add_data: "Может добавлять данные (добавки, заметки и т.д.)"
+send_invitation: "Отправить приглашение"
+back_to_dossier: "Назад к досье"
+
+# Relations
+
+# Invitation email
+invite_email_subject: "%s добавил вас в inou"
+invite_email_body: "%s добавил ваше медицинское досье в inou, чтобы вы могли просматривать и управлять своими медицинскими данными."
+invite_email_cta: "Войти для просмотра"
+continue: "Продолжить"
+i_am_their: "Я их..."
+
+# Simple relation names (for display)
+my_role: "моя роль"
+role: "role"
+section_privacy: "Конфиденциальность"
+people_with_access_count: "с доступом"
+view_audit_log: "Журнал действий"
+export_data: "Download my data"
+relation_with: "Отношение с"
+audit_log: "Журнал действий"
+audit_log_intro: "История активности для"
+audit_log_desc: "Отслеживание доступа и изменений в досье"
+install_title: "Подключить к Claude"
+install_intro: "Настройте мост inou, чтобы Claude мог анализировать ваши медицинские данные"
+install_step1: "Шаг 1: Скачать"
+install_step1_desc: "Получите мост для вашей платформы"
+install_download_intro: "Скачайте мост inou для вашей операционной системы:"
+install_step2: "Шаг 2: Настроить"
+install_step2_desc: "Добавьте в конфигурацию Claude Desktop"
+install_config_intro: "Добавьте это в файл конфигурации Claude Desktop:"
+install_step3: "Шаг 3: Проверить"
+install_step3_desc: "Проверьте соединение"
+install_test_intro: "Перезапустите Claude Desktop и спросите: 'Покажи мои профили inou'"
+nav_install: "Подключить к Claude"
+nav_home: "Главная"
+rate_limit_exceeded: "Слишком много попыток регистрации с вашего местоположения. Пожалуйста, попробуйте завтра."
+
+sex_male: "мужской"
+sex_female: "женский"
+sex_na: "другой"
+
+# Friend invite email
+friend_invite_subject: "Посмотри — %s"
+friend_invite_p1: "Я использую inou — безопасный способ хранить медицинские данные и анализировать их с помощью ИИ. Там хранится вся медицинская информация моей семьи — снимки, результаты анализов, медицинские записи — и я подумал, что тебе тоже может пригодиться."
+friend_invite_p2: "Настоящая сила в том, что можно использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение, отследить тенденции или просто задать вопросы простым языком и получить понятные ответы."
+friend_invite_btn: "Посмотреть inou"
+friend_invite_dear: "Привет, %s!"
+rel_0: "ты"
+rel_1: "Родитель"
+rel_2: "Ребёнок"
+rel_3: "Супруг"
+rel_4: "Брат/Сестра"
+rel_5: "Опекун"
+rel_6: "Сиделка"
+rel_7: "Тренер"
+rel_8: "Врач"
+rel_9: "Друг"
+rel_10: "Другое"
+rel_99: "Demo"
+select_relation: "Выберите отношение..."
+
+# Категории
+category000: Визуализация
+category001: Документ
+category002: Анализ
+category003: Геном
+category004: Загрузка
+category005: Консультация
+category006: Диагноз
+category007: Результат визуализации
+category008: Результат ЭЭГ
+category009: Показатель здоровья
+category010: Физическая активность
+category011: Лекарство
+category012: Добавка
+category013: Питание
+category014: Фертильность
+category015: Симптом
+category016: Заметка
+category017: История болезни
+category018: Семейный анамнез
+category019: Операция
+category020: Госпитализация
+category021: Данные о рождении
+category022: Медицинское устройство
+category023: Терапия
+category024: Оценка
+category025: Медицинский работник
+category026: Вопрос
+
+# Genome
+genome_english_only: "Вся генетическая информация на английском языке. Используйте Claude, чтобы обсудить её на русском."
+genome_variants: "вариантов"
+genome_hidden: "скрыто"
+genome_show_all_categories: "Показать все %d категорий"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/sv.yaml b/lang/sv.yaml
new file mode 100644
index 0000000..c4be91d
--- /dev/null
+++ b/lang/sv.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "Din hälsodata."
+headline_2: "Din AI."
+headline_3: "Dina svar."
+intro: "Ladda upp bilddiagnostik, labbresultat och mer. Anslut din AI för att hjälpa dig förstå vad du tittar på."
+email: "E-post"
+get_started: "Kom igång"
+data_yours: "Din data förblir din"
+never_training: "Används aldrig för träning"
+never_training_desc: "Dina bilder används aldrig för att träna AI-modeller."
+never_shared: "Delas aldrig"
+never_shared_desc: "Vi delar aldrig din data med någon."
+encrypted: "Krypterad lagring"
+encrypted_desc: "All data krypterad i vila."
+delete: "Radera när som helst"
+delete_desc: "Din data, din kontroll."
+
+# Verify
+check_email: "Kolla din e-post"
+code_sent_to: "Vi skickade en 6-siffrig kod till"
+verification_code: "Verifieringskod"
+verify: "Verifiera"
+use_different_email: "Använd en annan e-post"
+invalid_code: "Ogiltig eller utgången kod. Försök igen."
+
+# Onboard
+create_dossier: "Skapa din dossier"
+create_profile_intro: "Berätta om dig själv för att komma igång."
+name: "Namn"
+name_placeholder: "Ditt namn"
+date_of_birth: "Födelsedatum"
+sex_at_birth: "Kön vid födseln"
+female: "Kvinna"
+male: "Man"
+create_my_dossier: "Skapa min dossier"
+
+# Minor error
+must_be_18: "Du måste vara 18 för att skapa ett konto"
+minor_explanation: "Om du skapar detta för någon annan, börja med din egen profil först. Detta säkerställer att bara du kan komma åt deras hälsodata."
+minor_next_steps: "Efter att du skapat din dossier kan du lägga till andra."
+use_different_dob: "Använd ett annat födelsedatum"
+
+# Minor login block
+minor_login_blocked: "Du måste vara 18 för att logga in"
+minor_ask_guardian: "Be %s om åtkomst till din dossier."
+minor_ask_guardian_generic: "Be en förälder eller vårdnadshavare om åtkomst till din dossier."
+
+# Dashboard
+dossiers: "Dossier"
+dossiers_intro: "Hantera hälsodata för dig själv eller andra"
+you: "du"
+view: "Visa"
+save: "Spara"
+cancel: "Avbryt"
+add_dossier: "Lägg till dossier"
+edit_dossier: "Redigera dossier"
+care: "vård"
+logout: "Logga ut"
+
+# Profile detail
+back_to_dossiers: "Tillbaka till dossier"
+born: "Född"
+no_access_yet: "Bara du har åtkomst."
+people_with_access: "Personer med åtkomst"
+share_access: "Dela åtkomst"
+can_edit: "kan lägga till data"
+remove: "Ta bort"
+confirm_revoke: "Ta bort åtkomst?"
+
+# Dossier sections
+section_imaging: "Bilddiagnostik"
+section_labs: "Labb"
+section_uploads: "Uppladdningar"
+section_vitals: "Vitalvärden"
+section_medications: "Läkemedel"
+section_records: "Journaler"
+section_journal: "Dagbok"
+section_genetics: "Genetik"
+section_privacy: "Integritet"
+
+# Section summaries
+imaging_summary: "%d undersökningar · %d snitt"
+no_imaging: "Ingen bilddata"
+no_lab_data: "Ingen labbdata"
+no_genetics: "Ingen genetisk data"
+no_files: "Inga filer"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d filer (%s)"
+series_count: "%d serier"
+vitals_desc: "Blodtryck, puls, SpO₂, vikt, blodsocker"
+medications_desc: "Recept och kosttillskott"
+records_desc: "Kliniska anteckningar och journaler"
+journal_desc: "Symtom, smärta och observationer"
+
+# Buttons and actions
+open_viewer: "Öppna visare"
+manage: "Hantera"
+show_all_studies: "Visa alla %d undersökningar..."
+coming_soon: "Kommer snart"
+
+# Upload page
+upload_files: "Ladda upp hälsodata"
+upload_files_intro: "Ladda upp medicinsk bilddiagnostik, labbresultat, genomfiler eller hälsorelaterade dokument."
+upload_hint_broad: "DICOM, PDF, CSV, VCF med mera"
+uploading: "Laddar upp..."
+files_uploaded: "filer uppladdade"
+upload_scans: "Ladda upp skanningar"
+upload_scans_intro: "Ladda upp en mapp med DICOM-filer från din bildundersökning."
+upload_drop: "Klicka eller dra en mapp hit"
+upload_hint: "Endast DICOM-mappar"
+
+# Add profile
+add_dossier_intro: "Lägg till någon vars hälsodata du vill hantera."
+email_optional: "E-post (valfritt)"
+email_optional_hint: "Om de är 18 kan de logga in själva"
+your_relation: "Din relation till dem"
+select_relation: "Välj..."
+i_provide_care: "Jag ger vård till denna person"
+i_am_their: "Jag är deras..."
+
+# Share access
+share_access_intro: "Bjud in någon att få åtkomst"
+their_relation: "Deras relation till denna person"
+can_add_data: "Kan lägga till data (kosttillskott, anteckningar, etc.)"
+send_invitation: "Skicka inbjudan"
+back_to_dossier: "Tillbaka till dossier"
+
+# Relations
+my_role: "min roll"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s lade till dig i inou"
+invite_email_body: "%s lade till din hälsodossier i inou så att du kan visa och hantera din medicinska data."
+invite_email_cta: "Logga in för att visa"
+continue: "Fortsätt"
+
+# Access management
+people_with_access_count: "personer med åtkomst"
+view_audit_log: "Visa aktivitetslogg"
+export_data: "Download my data"
+relation_with: "Relation till"
+audit_log: "Aktivitetslogg"
+audit_log_intro: "Aktivitetshistorik för"
+audit_log_desc: "Spåra vem som har åtkomst till eller ändrat denna dossier"
+
+# Install / Connect
+install_title: "Anslut till Claude"
+install_intro: "Konfigurera inou-bryggan för att låta Claude analysera din hälsodata"
+install_step1: "Steg 1: Ladda ner"
+install_step1_desc: "Hämta bryggan för din plattform"
+install_download_intro: "Ladda ner inou-bryggan för ditt operativsystem:"
+install_step2: "Steg 2: Konfigurera"
+install_step2_desc: "Lägg till i Claude Desktop-konfigurationen"
+install_config_intro: "Lägg till detta i din Claude Desktop-konfigurationsfil:"
+install_step3: "Steg 3: Testa"
+install_step3_desc: "Verifiera anslutningen"
+install_test_intro: "Starta om Claude Desktop och fråga: 'Visa mina inou-profiler'"
+nav_install: "Anslut till Claude"
+nav_home: "Hem"
+
+# Status
+pending: "väntande"
+rate_limit_exceeded: "För många registreringsförsök från din plats. Försök igen imorgon."
+
+# Sex display
+sex_male: "man"
+sex_female: "kvinna"
+sex_na: "annat"
+
+# Friend invite email
+friend_invite_subject: "Kolla in det här — %s"
+friend_invite_p1: "Jag använder inou, det säkra sättet att lagra hälsodata och utforska den med AI. Det håller all min familjs hälsoinformation på ett ställe — bildstudier, labbresultat, journaler — och jag tänkte att det kanske kunde vara användbart för dig också."
+friend_invite_p2: "Den verkliga kraften ligger i att kunna använda AI för att förstå allt: förstå vad en rapport faktiskt betyder, upptäcka trender över tid, eller bara ställa frågor på vanlig svenska och få tydliga svar."
+friend_invite_btn: "Upptäck inou"
+friend_invite_dear: "Hej %s,"
+rel_0: "du"
+rel_1: "Förälder"
+rel_2: "Barn"
+rel_3: "Make/Maka"
+rel_4: "Syskon"
+rel_5: "Vårdnadshavare"
+rel_6: "Vårdgivare"
+rel_7: "Coach"
+rel_8: "Läkare"
+rel_9: "Vän"
+rel_10: "Annat"
+rel_99: "Demo"
+select_relation: "Välj relation..."
+
+# Kategorier
+category000: Bilddiagnostik
+category001: Dokument
+category002: Labbresultat
+category003: Genom
+category004: Uppladdning
+category005: Konsultation
+category006: Diagnos
+category007: Bildresultat
+category008: EEG-resultat
+category009: Vitalvärde
+category010: Träning
+category011: Läkemedel
+category012: Tillskott
+category013: Näring
+category014: Fertilitet
+category015: Symptom
+category016: Anteckning
+category017: Sjukdomshistorik
+category018: Familjehistorik
+category019: Kirurgi
+category020: Sjukhusvistelse
+category021: Födelsedata
+category022: Medicinsk utrustning
+category023: Terapi
+category024: Bedömning
+category025: Vårdgivare
+category026: Fråga
+
+# Genome
+genome_english_only: "All genetisk information är på engelska. Använd Claude för att diskutera det på svenska."
+genome_variants: "varianter"
+genome_hidden: "dolda"
+genome_show_all_categories: "Visa alla %d kategorier"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lang/zh.yaml b/lang/zh.yaml
new file mode 100644
index 0000000..680b577
--- /dev/null
+++ b/lang/zh.yaml
@@ -0,0 +1,236 @@
+# Landing
+headline_1: "你的健康数据。"
+headline_2: "你的AI。"
+headline_3: "你的答案。"
+intro: "上传影像、化验等。连接AI帮助你理解所看到的内容。"
+email: "电子邮件"
+get_started: "开始使用"
+data_yours: "你的数据归你所有"
+never_training: "从不用于训练"
+never_training_desc: "你的图像从不用于训练AI模型。"
+never_shared: "从不共享"
+never_shared_desc: "我们从不与任何人共享你的数据。"
+encrypted: "加密存储"
+encrypted_desc: "所有数据静态加密。"
+delete: "随时删除"
+delete_desc: "你的数据,你做主。"
+
+# Verify
+check_email: "检查你的邮箱"
+code_sent_to: "我们已发送6位验证码到"
+verification_code: "验证码"
+verify: "验证"
+use_different_email: "使用其他邮箱"
+invalid_code: "验证码无效或已过期。请重试。"
+
+# Onboard
+create_dossier: "创建你的档案"
+create_profile_intro: "告诉我们关于你的信息以开始使用。"
+name: "姓名"
+name_placeholder: "你的姓名"
+date_of_birth: "出生日期"
+sex_at_birth: "出生时性别"
+female: "女"
+male: "男"
+create_my_dossier: "创建我的档案"
+
+# Minor error
+must_be_18: "你必须年满18岁才能创建账户"
+minor_explanation: "如果你是为他人设置,请先从你自己的个人资料开始。这确保只有你才能访问他们的健康数据。"
+minor_next_steps: "创建档案后,你可以添加其他人。"
+use_different_dob: "使用其他出生日期"
+
+# Minor login block
+minor_login_blocked: "你必须年满18岁才能登录"
+minor_ask_guardian: "请联系%s访问你的档案。"
+minor_ask_guardian_generic: "请联系父母或监护人访问你的档案。"
+
+# Dashboard
+dossiers: "档案"
+dossiers_intro: "管理你或他人的健康数据"
+you: "你"
+view: "查看"
+save: "保存"
+cancel: "取消"
+add_dossier: "添加档案"
+edit_dossier: "编辑档案"
+care: "护理"
+logout: "退出登录"
+
+# Profile detail
+back_to_dossiers: "返回档案"
+born: "出生"
+no_access_yet: "只有你有访问权限。"
+people_with_access: "有访问权限的人"
+share_access: "共享访问权限"
+can_edit: "可以添加数据"
+remove: "移除"
+confirm_revoke: "移除访问权限?"
+
+# Dossier sections
+section_imaging: "影像"
+section_labs: "化验"
+section_uploads: "上传"
+section_vitals: "生命体征"
+section_medications: "药物"
+section_records: "记录"
+section_journal: "日记"
+section_genetics: "遗传"
+section_privacy: "隐私"
+
+# Section summaries
+imaging_summary: "%d项检查 · %d张切片"
+no_imaging: "无影像数据"
+no_lab_data: "无化验数据"
+no_genetics: "无遗传数据"
+no_files: "无文件"
+no_upload_access: "You don't have permission to upload"
+files_summary: "%d个文件(%s)"
+series_count: "%d个序列"
+vitals_desc: "血压、心率、血氧、体重、血糖"
+medications_desc: "处方药和补充剂"
+records_desc: "临床记录和病历"
+journal_desc: "症状、疼痛和观察"
+
+# Buttons and actions
+open_viewer: "打开查看器"
+manage: "管理"
+show_all_studies: "显示全部%d项检查..."
+coming_soon: "即将推出"
+
+# Upload page
+upload_files: "上传健康数据"
+upload_files_intro: "上传医学影像、化验结果、基因组文件或任何健康相关文档。"
+upload_hint_broad: "DICOM、PDF、CSV、VCF等"
+uploading: "上传中..."
+files_uploaded: "文件已上传"
+upload_scans: "上传扫描"
+upload_scans_intro: "上传包含影像检查DICOM文件的文件夹。"
+upload_drop: "点击或拖拽文件夹到此处"
+upload_hint: "仅限DICOM文件夹"
+
+# Add profile
+add_dossier_intro: "添加你想要管理其健康数据的人。"
+email_optional: "电子邮件(可选)"
+email_optional_hint: "如果年满18岁,他们可以自己登录"
+your_relation: "你与此人的关系"
+select_relation: "选择..."
+i_provide_care: "我为此人提供护理"
+i_am_their: "我是他们的..."
+
+# Share access
+share_access_intro: "邀请他人访问"
+their_relation: "他们与此人的关系"
+can_add_data: "可以添加数据(补充剂、备注等)"
+send_invitation: "发送邀请"
+back_to_dossier: "返回档案"
+
+# Relations
+my_role: "我的角色"
+role: "role"
+
+# Invitation email
+invite_email_subject: "%s将你添加到inou"
+invite_email_body: "%s将你的健康档案添加到inou,这样你可以查看和管理你的医疗数据。"
+invite_email_cta: "登录查看"
+continue: "继续"
+
+# Access management
+people_with_access_count: "有访问权限的人"
+view_audit_log: "查看审计日志"
+export_data: "Download my data"
+relation_with: "与...的关系"
+audit_log: "审计日志"
+audit_log_intro: "活动历史"
+audit_log_desc: "跟踪谁访问或修改了此档案"
+
+# Install / Connect
+install_title: "连接到Claude"
+install_intro: "设置inou桥接器以让Claude分析你的健康数据"
+install_step1: "步骤1:下载"
+install_step1_desc: "获取适合你平台的桥接器"
+install_download_intro: "为你的操作系统下载inou桥接器:"
+install_step2: "步骤2:配置"
+install_step2_desc: "添加到Claude桌面配置"
+install_config_intro: "将此添加到你的Claude桌面配置文件:"
+install_step3: "步骤3:测试"
+install_step3_desc: "验证连接"
+install_test_intro: "重启Claude桌面并询问:'显示我的inou档案'"
+nav_install: "连接到Claude"
+nav_home: "首页"
+
+# Status
+pending: "待处理"
+rate_limit_exceeded: "你所在位置的注册尝试次数过多。请明天再试。"
+
+# Sex display
+sex_male: "男"
+sex_female: "女"
+sex_na: "其他"
+
+# Friend invite email
+friend_invite_subject: "看看这个 — %s"
+friend_invite_p1: "我一直在用inou,一种安全存储健康数据并用AI分析的方式。它把我家人的所有健康信息都放在一个地方——影像检查、化验结果、病历——我想这对你也可能有用。"
+friend_invite_p2: "真正的力量在于能用AI理解一切:理解报告实际意味着什么,发现随时间变化的趋势,或者只是用普通话提问并获得清晰的答案。"
+friend_invite_btn: "了解inou"
+friend_invite_dear: "%s,"
+rel_0: "你"
+rel_1: "父母"
+rel_2: "子女"
+rel_3: "配偶"
+rel_4: "兄弟姐妹"
+rel_5: "监护人"
+rel_6: "护理者"
+rel_7: "教练"
+rel_8: "医生"
+rel_9: "朋友"
+rel_10: "其他"
+rel_99: "Demo"
+select_relation: "选择关系..."
+
+# 类别
+category000: 影像
+category001: 文档
+category002: 检验结果
+category003: 基因组
+category004: 上传
+category005: 咨询
+category006: 诊断
+category007: 影像检查结果
+category008: 脑电图结果
+category009: 生命体征
+category010: 运动
+category011: 药物
+category012: 补充剂
+category013: 营养
+category014: 生育
+category015: 症状
+category016: 笔记
+category017: 病史
+category018: 家族史
+category019: 手术
+category020: 住院
+category021: 出生数据
+category022: 医疗设备
+category023: 治疗
+category024: 评估
+category025: 医疗服务提供者
+category026: 问题
+
+# Genome
+genome_english_only: "所有基因信息均为英文。使用Claude可以用中文讨论。"
+genome_variants: "变异"
+genome_hidden: "隐藏"
+genome_show_all_categories: "显示全部%d个类别"
+
+# API
+api_token: "API Token"
+api_token_use: "[EN] Use this token to authenticate API requests:"
+api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data."
+api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants."
+api_token_generate: "Generate Token"
+api_token_regenerate: "Regenerate Token"
+api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated."
+api_authentication: "Authentication"
+api_auth_instructions: "[EN] Include your API token in the Authorization header:"
+copy: "Copy"
diff --git a/lib/._db_schema.go b/lib/._db_schema.go
new file mode 100644
index 0000000..416537b
Binary files /dev/null and b/lib/._db_schema.go differ
diff --git a/portal/static/download/inou.mcpb b/portal/static/download/inou.mcpb
new file mode 100644
index 0000000..da559ce
Binary files /dev/null and b/portal/static/download/inou.mcpb differ
diff --git a/portal/static/download/things-mcpb.mcpb b/portal/static/download/things-mcpb.mcpb
new file mode 100644
index 0000000..fd3e295
Binary files /dev/null and b/portal/static/download/things-mcpb.mcpb differ
diff --git a/restart.sh b/restart.sh
new file mode 100644
index 0000000..5020bd2
--- /dev/null
+++ b/restart.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+cd /tank/inou
+
+echo "=== Inou Restart ==="
+
+# Stop
+pkill -x inou-viewer 2>/dev/null && echo "Viewer: stopped" || echo "Viewer: not running"
+pkill -x inou-portal 2>/dev/null && echo "Portal: stopped" || echo "Portal: not running"
+sleep 1
+
+# Start
+./start.sh
diff --git a/smtp.env b/smtp.env
new file mode 100644
index 0000000..5934b58
--- /dev/null
+++ b/smtp.env
@@ -0,0 +1,5 @@
+SMTP_HOST=smtp.protonmail.ch
+SMTP_PORT=587
+SMTP_USER=noreply@inou.com
+SMTP_TOKEN=YKEPACTZJE6VJJBE
+SMTP_FROM_NAME=inou health
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000..8bf3f96
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# Start Inou services
+cd /tank/inou
+
+echo "=== Inou Start ==="
+
+# API (start first, portal proxies to it)
+if pgrep -f "bin/api$" > /dev/null; then
+ echo "API: already running (PID $(pgrep -f 'bin/api$'))"
+else
+ ./bin/api >> /tank/inou/logs/api.log 2>&1 &
+ sleep 0.5
+ if pgrep -f "bin/api$" > /dev/null; then
+ echo "API: started (PID $!)"
+ else
+ echo "API: FAILED - check logs/api.log"
+ fi
+fi
+
+# Viewer
+if pgrep -f "bin/viewer$" > /dev/null; then
+ echo "Viewer: already running (PID $(pgrep -f 'bin/viewer$'))"
+else
+ ./bin/viewer >> /tank/inou/logs/viewer.log 2>&1 &
+ sleep 0.5
+ if pgrep -f "bin/viewer$" > /dev/null; then
+ echo "Viewer: started (PID $!)"
+ else
+ echo "Viewer: FAILED - check logs/viewer.log"
+ fi
+fi
+
+# Portal
+if pgrep -f "bin/portal$" > /dev/null; then
+ echo "Portal: already running (PID $(pgrep -f 'bin/portal$'))"
+else
+ ./bin/portal >> /tank/inou/logs/portal.log 2>&1 &
+ sleep 0.5
+ if pgrep -f "bin/portal$" > /dev/null; then
+ echo "Portal: started (PID $!)"
+ else
+ echo "Portal: FAILED - check logs/portal.log"
+ fi
+fi
+
+echo ""
+echo "Portal: https://inou.com"
+echo "Viewer: https://inou.com:8767"
+echo "API: https://inou.com/api/* (internal :8082)"
diff --git a/static/4bit.png b/static/4bit.png
new file mode 100644
index 0000000..5bba571
Binary files /dev/null and b/static/4bit.png differ
diff --git a/static/4bit.webp b/static/4bit.webp
new file mode 100644
index 0000000..ae2f8bd
Binary files /dev/null and b/static/4bit.webp differ
diff --git a/static/8bit.png b/static/8bit.png
new file mode 100644
index 0000000..100d98f
Binary files /dev/null and b/static/8bit.png differ
diff --git a/static/api-docs.html b/static/api-docs.html
new file mode 100644
index 0000000..f37c163
--- /dev/null
+++ b/static/api-docs.html
@@ -0,0 +1,71 @@
+
+
+inou API Documentation
+
+inou Health Dossier API
+
+Authentication
+All endpoints require a token query parameter - your authentication token (dossier GUID).
+
+Base URL
+https://inou.com
+
+Endpoints
+
+GET /api/dossiers
+List all patient dossiers accessible to this account.
+Parameters:
+
+- token (required) - Your authentication token
+- format (optional) - Set to "text" for plain text output
+
+Example: GET https://inou.com/api/dossiers?token=YOUR_TOKEN
+
+GET /api/studies
+List all imaging studies in a dossier.
+Parameters:
+
+- token (required) - Your authentication token
+- dossier_guid (required) - The dossier GUID to query
+- format (optional) - Set to "text" for plain text output
+
+Example: GET https://inou.com/api/studies?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID
+
+GET /api/series
+List all series in a study.
+Parameters:
+
+- token (required) - Your authentication token
+- dossier_guid (required) - The dossier GUID
+- study_guid (required) - The study GUID
+- filter (optional) - Filter by description (e.g., "T1", "FLAIR", "SAG")
+- format (optional) - Set to "text" for plain text output
+
+Example: GET https://inou.com/api/series?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&study_guid=STUDY_ID
+
+GET /api/slices
+List all slices in a series with position data.
+Parameters:
+
+- token (required) - Your authentication token
+- dossier_guid (required) - The dossier GUID
+- series_guid (required) - The series GUID
+- format (optional) - Set to "text" for plain text output
+
+Example: GET https://inou.com/api/slices?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&series_guid=SERIES_ID
+
+GET /image/{slice_guid}
+Fetch a slice as PNG image.
+Parameters:
+
+- slice_guid (in path, required) - The slice GUID
+- token (required) - Your authentication token
+- ww (optional) - Window width for contrast (Brain=80, Bone=2000)
+- wc (optional) - Window center for brightness (Brain=40, Bone=500)
+
+Example: GET https://inou.com/image/SLICE_GUID?token=YOUR_TOKEN
+
+Default response is JSON. Add &format=text for plain text output.
+
+
+
diff --git a/static/api-docs.txt b/static/api-docs.txt
new file mode 100644
index 0000000..71128b9
--- /dev/null
+++ b/static/api-docs.txt
@@ -0,0 +1,78 @@
+inou Health API
+===============
+
+Base URL: https://inou.com
+
+Authentication: Bearer token in Authorization header, or token query parameter.
+Your token is your dossier ID (16-character hex).
+
+Example: Authorization: Bearer abc123def456789a
+
+
+DATA TYPES
+----------
+- Imaging: MRI, CT, X-ray, ultrasound (DICOM format)
+- Labs: Blood tests, metabolic panels, etc.
+- Genome: SNP variants with clinical annotations
+
+
+IMAGING ENDPOINTS
+-----------------
+
+GET /api/v1/dossiers
+ List all dossiers accessible to your account.
+
+GET /api/v1/dossiers/{dossier}/entries?category=imaging
+ List imaging studies for a dossier.
+
+GET /api/v1/dossiers/{dossier}/entries?parent={study}
+ List series in a study.
+ Optional: filter (e.g. T1, FLAIR, AX, SAG)
+
+GET /api/v1/dossiers/{dossier}/entries?parent={series}
+ List slices in a series with position data.
+
+GET /image/{slice}?token={dossier}
+ Fetch slice as PNG image.
+ Optional: ww (window width), wc (window center)
+
+GET /contact-sheet.webp/{series}?token={dossier}
+ Fetch thumbnail grid for navigation.
+ Optional: ww, wc
+
+
+LAB ENDPOINTS
+-------------
+
+GET /api/labs/tests?dossier={dossier}
+ List all available lab test names.
+
+GET /api/labs/results?dossier={dossier}&names={names}
+ Get lab results for specified tests.
+ Required: names (comma-separated test names)
+ Optional: from, to (YYYY-MM-DD), latest (true/false)
+
+
+GENOME ENDPOINTS
+----------------
+
+GET /api/categories?dossier={dossier}
+ Get top-level observation categories.
+ Optional: type=genome (for genome categories)
+ Optional: category={category} (for subcategories)
+
+GET /api/genome?dossier={dossier}
+ Query genome variants.
+ Optional: gene (e.g. MTHFR, COMT)
+ Optional: search (gene, subcategory, or summary text)
+ Optional: category (filter by category)
+ Optional: rsids (comma-separated rs numbers)
+ Optional: min_magnitude (0-4)
+ Optional: include_hidden (true/false)
+
+
+NAVIGATION FLOW
+---------------
+Imaging: dossiers → studies → series → slices → image
+Labs: dossiers → tests → results
+Genome: dossiers → categories → variants
diff --git a/static/app-ads.txt b/static/app-ads.txt
new file mode 100644
index 0000000..e69de29
diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png
new file mode 100644
index 0000000..37e7fba
Binary files /dev/null and b/static/apple-touch-icon.png differ
diff --git a/static/download/inou.mcpb b/static/download/inou.mcpb
new file mode 100644
index 0000000..8a1397e
Binary files /dev/null and b/static/download/inou.mcpb differ
diff --git a/static/download/mac/amd64/inou_bridge b/static/download/mac/amd64/inou_bridge
new file mode 100644
index 0000000..ba5cf71
Binary files /dev/null and b/static/download/mac/amd64/inou_bridge differ
diff --git a/static/download/mac/arm64/inou_bridge b/static/download/mac/arm64/inou_bridge
new file mode 100644
index 0000000..c185e2c
Binary files /dev/null and b/static/download/mac/arm64/inou_bridge differ
diff --git a/static/download/win/amd64/inou_bridge.exe b/static/download/win/amd64/inou_bridge.exe
new file mode 100644
index 0000000..7eb7147
Binary files /dev/null and b/static/download/win/amd64/inou_bridge.exe differ
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..0f13ed8
--- /dev/null
+++ b/static/favicon.ico
@@ -0,0 +1 @@
+
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..0f13ed8
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/static/input.css b/static/input.css
new file mode 100644
index 0000000..029729d
--- /dev/null
+++ b/static/input.css
@@ -0,0 +1,738 @@
+/* ========================================
+ INOU INPUT SCREEN
+ Modern health data input with voice, camera, text
+ ======================================== */
+
+/* Container */
+.input-container {
+ max-width: 480px;
+ margin: 0 auto;
+ padding: 24px 20px 32px;
+ min-height: 100vh;
+ min-height: 100dvh; /* Dynamic viewport for mobile */
+ display: flex;
+ flex-direction: column;
+}
+
+/* Header */
+.input-header {
+ text-align: center;
+ margin-bottom: 28px;
+}
+
+.input-header h1 {
+ font-size: 1.75rem;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0 0 6px;
+ letter-spacing: -0.02em;
+}
+
+.input-subtitle {
+ font-size: 1rem;
+ color: var(--text-muted);
+ font-weight: 300;
+ margin: 0;
+}
+
+/* ========================================
+ SEGMENTED CONTROL
+ ======================================== */
+.segment-control {
+ display: flex;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 4px;
+ margin-bottom: 24px;
+ gap: 4px;
+}
+
+.segment-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ font-family: inherit;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.segment-btn:hover {
+ color: var(--text);
+}
+
+.segment-btn.active {
+ background: var(--bg-card);
+ color: var(--accent);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+.segment-icon {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+/* ========================================
+ MODE PANELS
+ ======================================== */
+.mode-panels {
+ flex: 1;
+ position: relative;
+}
+
+.mode-panel {
+ display: none;
+ animation: fadeIn 0.25s ease;
+}
+
+.mode-panel.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ========================================
+ TEXT INPUT PANEL
+ ======================================== */
+.text-input-wrap {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.text-input-wrap:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-light);
+}
+
+.text-input {
+ width: 100%;
+ min-height: 140px;
+ padding: 16px;
+ font-family: inherit;
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--text);
+ background: transparent;
+ border: none;
+ resize: none;
+ outline: none;
+}
+
+.text-input::placeholder {
+ color: var(--text-subtle);
+}
+
+.text-input-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.char-count {
+ font-size: 0.8rem;
+ color: var(--text-subtle);
+}
+
+.input-hints {
+ display: flex;
+ gap: 6px;
+}
+
+.hint-tag {
+ font-size: 0.75rem;
+ padding: 4px 8px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ color: var(--text-muted);
+}
+
+/* ========================================
+ VOICE INPUT PANEL
+ ======================================== */
+.voice-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 32px 16px;
+}
+
+/* Mic Button */
+.mic-btn {
+ position: relative;
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ background: var(--bg-card);
+ border: 2px solid var(--border);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.mic-btn:hover {
+ border-color: var(--accent);
+ transform: scale(1.02);
+}
+
+.mic-btn.listening {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.mic-icon {
+ width: 36px;
+ height: 36px;
+ color: var(--text-muted);
+ transition: color 0.2s;
+}
+
+.mic-btn.listening .mic-icon {
+ color: var(--accent);
+}
+
+/* Pulse Animation */
+.mic-pulse {
+ position: absolute;
+ inset: -8px;
+ border-radius: 50%;
+ border: 2px solid var(--accent);
+ opacity: 0;
+ transform: scale(1);
+ pointer-events: none;
+}
+
+.mic-btn.listening .mic-pulse {
+ animation: pulse 1.5s ease-out infinite;
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.6;
+ transform: scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.4);
+ }
+}
+
+/* Status Text */
+.mic-status {
+ margin-top: 16px;
+ font-size: 0.95rem;
+ color: var(--text-muted);
+ font-weight: 400;
+ text-align: center;
+}
+
+.mic-btn.listening + .mic-status {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+/* Transcript Area */
+.transcript-area {
+ width: 100%;
+ margin-top: 24px;
+ padding: 16px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ min-height: 100px;
+ display: none;
+}
+
+.transcript-area.has-content {
+ display: block;
+}
+
+.transcript-text {
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--text);
+ margin: 0;
+}
+
+.transcript-interim {
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--text-subtle);
+ font-style: italic;
+ margin: 4px 0 0;
+}
+
+/* Voice Unsupported */
+.voice-unsupported {
+ text-align: center;
+ padding: 32px;
+ color: var(--text-muted);
+}
+
+.voice-unsupported svg {
+ color: var(--text-subtle);
+ margin-bottom: 12px;
+}
+
+.voice-unsupported p {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.voice-fallback-hint {
+ margin-top: 8px !important;
+ font-size: 0.9rem !important;
+ color: var(--text-subtle) !important;
+}
+
+/* ========================================
+ CAMERA/SCAN PANEL
+ ======================================== */
+.camera-container {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+ min-height: 360px;
+}
+
+/* Camera Start State */
+.camera-start {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ min-height: 360px;
+}
+
+.camera-start-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 24px 32px;
+ background: var(--accent-light);
+ border: 2px dashed var(--accent);
+ border-radius: 16px;
+ color: var(--accent);
+ font-family: inherit;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.camera-start-btn:hover {
+ background: var(--accent);
+ border-style: solid;
+ color: white;
+}
+
+.camera-start-btn:hover svg {
+ stroke: white;
+}
+
+.camera-hint {
+ margin-top: 16px;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ text-align: center;
+ max-width: 280px;
+}
+
+/* Camera Viewfinder */
+.camera-viewfinder {
+ position: relative;
+ background: #000;
+}
+
+#camera-video {
+ width: 100%;
+ height: auto;
+ display: block;
+ max-height: 400px;
+ object-fit: cover;
+}
+
+.viewfinder-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.viewfinder-frame {
+ width: 85%;
+ height: 70%;
+ border: 2px solid rgba(255, 255, 255, 0.5);
+ border-radius: 8px;
+}
+
+/* Camera Controls */
+.camera-controls {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ padding: 20px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
+}
+
+.camera-ctrl-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+}
+
+.camera-ctrl-btn svg {
+ width: 22px;
+ height: 22px;
+ color: white;
+}
+
+.camera-ctrl-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Capture Button */
+.capture-btn {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: white;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ cursor: pointer;
+ padding: 4px;
+ transition: transform 0.1s;
+}
+
+.capture-btn:hover {
+ transform: scale(1.05);
+}
+
+.capture-btn:active {
+ transform: scale(0.95);
+}
+
+.capture-btn-inner {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background: white;
+ border: 2px solid #ddd;
+}
+
+/* Photo Preview */
+.photo-preview {
+ padding: 16px;
+}
+
+#preview-img {
+ width: 100%;
+ height: auto;
+ border-radius: 8px;
+ max-height: 300px;
+ object-fit: contain;
+ background: var(--bg);
+}
+
+.preview-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.preview-actions .btn {
+ flex: 1;
+}
+
+/* OCR Result */
+.ocr-result {
+ padding: 16px;
+}
+
+.ocr-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.ocr-header h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+}
+
+/* Result Type Badges */
+.result-type-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.result-type-badge.barcode {
+ background: var(--success-light);
+ color: var(--success);
+}
+
+.result-type-badge.text {
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.result-type-badge.empty {
+ background: var(--bg);
+ color: var(--text-muted);
+}
+
+.result-type-badge.error {
+ background: var(--danger-light);
+ color: var(--danger);
+}
+
+.ocr-text {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+ font-size: 0.95rem;
+ line-height: 1.6;
+ color: var(--text);
+ max-height: 200px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+}
+
+.ocr-text:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.ocr-text[contenteditable="true"] {
+ border-color: var(--accent);
+ background: var(--bg-card);
+}
+
+/* Barcode value styling - monospace, larger */
+.ocr-text.barcode-value {
+ font-family: "SF Mono", Monaco, "Courier New", monospace;
+ font-size: 1.25rem;
+ font-weight: 600;
+ text-align: center;
+ letter-spacing: 0.05em;
+ padding: 24px 16px;
+ color: var(--text);
+}
+
+.ocr-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.ocr-actions .btn {
+ flex: 1;
+}
+
+/* OCR Processing */
+.ocr-processing {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 64px 24px;
+ min-height: 300px;
+}
+
+.processing-spinner {
+ width: 48px;
+ height: 48px;
+ border: 3px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.ocr-processing p {
+ margin-top: 16px;
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+/* Camera Error */
+.camera-error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+ min-height: 300px;
+}
+
+.camera-error svg {
+ color: var(--danger);
+ margin-bottom: 16px;
+}
+
+.camera-error p {
+ font-size: 1rem;
+ color: var(--text-muted);
+ margin: 0 0 20px;
+}
+
+/* ========================================
+ SUBMIT SECTION
+ ======================================== */
+.submit-section {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid var(--border);
+}
+
+.submit-btn {
+ padding: 14px 24px;
+ font-size: 1.05rem;
+ gap: 10px;
+}
+
+.submit-btn:disabled {
+ background: var(--border);
+ color: var(--text-subtle);
+ cursor: not-allowed;
+}
+
+.submit-btn:disabled:hover {
+ background: var(--border);
+}
+
+.submit-icon {
+ width: 20px;
+ height: 20px;
+}
+
+/* ========================================
+ MOBILE RESPONSIVE
+ ======================================== */
+@media (max-width: 480px) {
+ .input-container {
+ padding: 16px 16px 24px;
+ }
+
+ .input-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .segment-btn {
+ padding: 10px 12px;
+ font-size: 0.9rem;
+ }
+
+ .segment-btn span {
+ display: none;
+ }
+
+ .segment-icon {
+ width: 24px;
+ height: 24px;
+ }
+
+ .mic-btn {
+ width: 80px;
+ height: 80px;
+ }
+
+ .mic-icon {
+ width: 32px;
+ height: 32px;
+ }
+
+ .text-input {
+ min-height: 120px;
+ font-size: 16px; /* Prevents zoom on iOS */
+ }
+
+ .input-hints {
+ display: none;
+ }
+
+ .camera-start {
+ min-height: 280px;
+ padding: 32px 16px;
+ }
+}
+
+/* Larger phones / tablets */
+@media (min-width: 481px) and (max-width: 768px) {
+ .input-container {
+ max-width: 520px;
+ }
+}
+
+/* ========================================
+ SAFE AREA (notch/home indicator)
+ ======================================== */
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+ .input-container {
+ padding-bottom: calc(24px + env(safe-area-inset-bottom));
+ }
+}
+
+/* ========================================
+ DARK MODE SUPPORT (future)
+ ======================================== */
+@media (prefers-color-scheme: dark) {
+ /* Ready for dark mode variables when inou supports it */
+}
diff --git a/static/input.js b/static/input.js
new file mode 100644
index 0000000..c6856c9
--- /dev/null
+++ b/static/input.js
@@ -0,0 +1,799 @@
+/**
+ * inou Health Input Screen
+ * Voice, Camera/OCR, and Text input for health data
+ */
+
+(function() {
+ 'use strict';
+
+ // ========================================
+ // STATE
+ // ========================================
+ const state = {
+ mode: 'type',
+ inputValue: '',
+ isListening: false,
+ recognition: null,
+ transcript: '',
+ interimTranscript: '',
+ stream: null,
+ facingMode: 'environment',
+ capturedImage: null,
+ ocrText: '',
+ scanResult: null // { type: 'barcode'|'text'|'empty'|'error', format?, value }
+ };
+
+ // ========================================
+ // DOM ELEMENTS
+ // ========================================
+ const elements = {};
+
+ function initElements() {
+ // Segment control
+ elements.segmentBtns = document.querySelectorAll('.segment-btn');
+ elements.modePanels = document.querySelectorAll('.mode-panel');
+
+ // Text input
+ elements.textInput = document.getElementById('text-input');
+ elements.charCount = document.getElementById('char-count');
+
+ // Voice input
+ elements.micBtn = document.getElementById('mic-btn');
+ elements.micStatus = document.getElementById('mic-status');
+ elements.transcriptArea = document.getElementById('transcript-area');
+ elements.transcriptText = document.getElementById('transcript-text');
+ elements.transcriptInterim = document.getElementById('transcript-interim');
+ elements.voiceUnsupported = document.getElementById('voice-unsupported');
+
+ // Camera/Scan
+ elements.cameraContainer = document.getElementById('camera-container');
+ elements.cameraStart = document.getElementById('camera-start');
+ elements.startCameraBtn = document.getElementById('start-camera-btn');
+ elements.cameraViewfinder = document.getElementById('camera-viewfinder');
+ elements.cameraVideo = document.getElementById('camera-video');
+ elements.switchCameraBtn = document.getElementById('switch-camera-btn');
+ elements.captureBtn = document.getElementById('capture-btn');
+ elements.closeCameraBtn = document.getElementById('close-camera-btn');
+ elements.photoPreview = document.getElementById('photo-preview');
+ elements.previewImg = document.getElementById('preview-img');
+ elements.retakeBtn = document.getElementById('retake-btn');
+ elements.processBtn = document.getElementById('process-btn');
+ elements.ocrResult = document.getElementById('ocr-result');
+ elements.ocrHeader = document.querySelector('.ocr-header');
+ elements.ocrText = document.getElementById('ocr-text');
+ elements.ocrEditBtn = document.getElementById('ocr-edit-btn');
+ elements.scanAnotherBtn = document.getElementById('scan-another-btn');
+ elements.useOcrBtn = document.getElementById('use-ocr-btn');
+ elements.ocrProcessing = document.getElementById('ocr-processing');
+ elements.cameraError = document.getElementById('camera-error');
+ elements.cameraErrorMsg = document.getElementById('camera-error-msg');
+ elements.retryCameraBtn = document.getElementById('retry-camera-btn');
+ elements.captureCanvas = document.getElementById('capture-canvas');
+
+ // Submit
+ elements.submitBtn = document.getElementById('submit-btn');
+ }
+
+ // ========================================
+ // MODE SWITCHING
+ // ========================================
+ function switchMode(mode) {
+ state.mode = mode;
+
+ // Update segment buttons
+ elements.segmentBtns.forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mode === mode);
+ });
+
+ // Update panels
+ elements.modePanels.forEach(panel => {
+ panel.classList.toggle('active', panel.dataset.panel === mode);
+ });
+
+ // Stop voice if switching away
+ if (mode !== 'speak' && state.isListening) {
+ stopListening();
+ }
+
+ // Stop camera if switching away
+ if (mode !== 'scan' && state.stream) {
+ stopCamera();
+ }
+
+ updateSubmitButton();
+ }
+
+ // ========================================
+ // TEXT INPUT
+ // ========================================
+ function initTextInput() {
+ elements.textInput.addEventListener('input', handleTextInput);
+ elements.textInput.addEventListener('focus', handleTextFocus);
+ }
+
+ function handleTextInput(e) {
+ const value = e.target.value;
+ state.inputValue = value;
+ elements.charCount.textContent = value.length;
+ autoResize(e.target);
+ updateSubmitButton();
+ }
+
+ function handleTextFocus() {
+ // Ensure mode is set to type when focusing text input
+ if (state.mode !== 'type') {
+ switchMode('type');
+ }
+ }
+
+ function autoResize(textarea) {
+ textarea.style.height = 'auto';
+ textarea.style.height = Math.max(140, textarea.scrollHeight) + 'px';
+ }
+
+ // ========================================
+ // VOICE INPUT (Web Speech API)
+ // ========================================
+ function initVoiceInput() {
+ // Check for support
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+
+ if (!SpeechRecognition) {
+ elements.micBtn.style.display = 'none';
+ elements.micStatus.style.display = 'none';
+ elements.voiceUnsupported.style.display = 'block';
+ return;
+ }
+
+ // Initialize recognition
+ state.recognition = new SpeechRecognition();
+ state.recognition.continuous = true;
+ state.recognition.interimResults = true;
+ state.recognition.lang = navigator.language || 'en-US';
+
+ // Event handlers
+ state.recognition.onstart = () => {
+ state.isListening = true;
+ elements.micBtn.classList.add('listening');
+ elements.micStatus.textContent = 'Listening...';
+ };
+
+ state.recognition.onend = () => {
+ state.isListening = false;
+ elements.micBtn.classList.remove('listening');
+ elements.micStatus.textContent = 'Tap to start speaking';
+
+ // Finalize any interim transcript
+ if (state.interimTranscript) {
+ state.transcript += state.interimTranscript;
+ state.interimTranscript = '';
+ updateTranscriptDisplay();
+ }
+ updateSubmitButton();
+ };
+
+ state.recognition.onerror = (event) => {
+ console.error('Speech recognition error:', event.error);
+ state.isListening = false;
+ elements.micBtn.classList.remove('listening');
+
+ let errorMsg = 'Tap to try again';
+ if (event.error === 'not-allowed') {
+ errorMsg = 'Microphone access denied. Check permissions.';
+ } else if (event.error === 'no-speech') {
+ errorMsg = 'No speech detected. Tap to try again.';
+ }
+ elements.micStatus.textContent = errorMsg;
+ };
+
+ state.recognition.onresult = (event) => {
+ let interim = '';
+ let final = '';
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const transcript = event.results[i][0].transcript;
+ if (event.results[i].isFinal) {
+ final += transcript + ' ';
+ } else {
+ interim += transcript;
+ }
+ }
+
+ if (final) {
+ state.transcript += final;
+ }
+ state.interimTranscript = interim;
+ updateTranscriptDisplay();
+ updateSubmitButton();
+ };
+
+ // Click handler
+ elements.micBtn.addEventListener('click', toggleListening);
+ }
+
+ function toggleListening() {
+ if (state.isListening) {
+ stopListening();
+ } else {
+ startListening();
+ }
+ }
+
+ function startListening() {
+ if (!state.recognition) return;
+
+ try {
+ state.recognition.start();
+ } catch (e) {
+ // Already started
+ console.log('Recognition already started');
+ }
+ }
+
+ function stopListening() {
+ if (!state.recognition) return;
+
+ try {
+ state.recognition.stop();
+ } catch (e) {
+ console.log('Recognition already stopped');
+ }
+ }
+
+ function updateTranscriptDisplay() {
+ const hasContent = state.transcript || state.interimTranscript;
+ elements.transcriptArea.classList.toggle('has-content', hasContent);
+ elements.transcriptText.textContent = state.transcript;
+ elements.transcriptInterim.textContent = state.interimTranscript;
+ }
+
+ // ========================================
+ // CAMERA / OCR
+ // ========================================
+ function initCamera() {
+ elements.startCameraBtn.addEventListener('click', startCamera);
+ elements.switchCameraBtn.addEventListener('click', switchCamera);
+ elements.captureBtn.addEventListener('click', capturePhoto);
+ elements.closeCameraBtn.addEventListener('click', closeCamera);
+ elements.retakeBtn.addEventListener('click', retakePhoto);
+ elements.processBtn.addEventListener('click', processOCR);
+ elements.ocrEditBtn.addEventListener('click', toggleOCREdit);
+ elements.scanAnotherBtn.addEventListener('click', scanAnother);
+ elements.useOcrBtn.addEventListener('click', useOCRText);
+ elements.retryCameraBtn.addEventListener('click', startCamera);
+ }
+
+ async function startCamera() {
+ try {
+ // Hide all states, show viewfinder
+ hideAllCameraStates();
+ elements.cameraViewfinder.style.display = 'block';
+
+ // Get camera stream
+ const constraints = {
+ video: {
+ facingMode: state.facingMode,
+ width: { ideal: 1920 },
+ height: { ideal: 1080 }
+ }
+ };
+
+ state.stream = await navigator.mediaDevices.getUserMedia(constraints);
+ elements.cameraVideo.srcObject = state.stream;
+ await elements.cameraVideo.play();
+
+ } catch (err) {
+ console.error('Camera error:', err);
+ showCameraError(getCameraErrorMessage(err));
+ }
+ }
+
+ function getCameraErrorMessage(err) {
+ if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
+ return 'Camera access denied. Please allow camera permissions.';
+ } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
+ return 'No camera found on this device.';
+ } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
+ return 'Camera is in use by another application.';
+ }
+ return 'Could not access camera. Please try again.';
+ }
+
+ function showCameraError(message) {
+ hideAllCameraStates();
+ elements.cameraErrorMsg.textContent = message;
+ elements.cameraError.style.display = 'flex';
+ }
+
+ function stopCamera() {
+ if (state.stream) {
+ state.stream.getTracks().forEach(track => track.stop());
+ state.stream = null;
+ }
+ elements.cameraVideo.srcObject = null;
+ }
+
+ function closeCamera() {
+ stopCamera();
+ hideAllCameraStates();
+ elements.cameraStart.style.display = 'flex';
+ }
+
+ async function switchCamera() {
+ state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
+ stopCamera();
+ await startCamera();
+ }
+
+ function capturePhoto() {
+ const video = elements.cameraVideo;
+ const canvas = elements.captureCanvas;
+
+ // Set canvas size to video size
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+
+ // Draw current frame
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(video, 0, 0);
+
+ // Get data URL
+ state.capturedImage = canvas.toDataURL('image/jpeg', 0.9);
+
+ // Stop camera and show preview
+ stopCamera();
+ hideAllCameraStates();
+ elements.previewImg.src = state.capturedImage;
+ elements.photoPreview.style.display = 'block';
+ }
+
+ function retakePhoto() {
+ state.capturedImage = null;
+ startCamera();
+ }
+
+ // ========================================
+ // BARCODE DETECTION
+ // ========================================
+
+ /**
+ * Try to detect barcode using native BarcodeDetector API
+ * Returns { found: boolean, format: string, value: string } or null
+ */
+ async function detectBarcodeNative(imageSource) {
+ if (!('BarcodeDetector' in window)) {
+ return null;
+ }
+
+ try {
+ const formats = await BarcodeDetector.getSupportedFormats();
+ const detector = new BarcodeDetector({ formats });
+ const barcodes = await detector.detect(imageSource);
+
+ if (barcodes.length > 0) {
+ const barcode = barcodes[0];
+ return {
+ found: true,
+ format: barcode.format,
+ value: barcode.rawValue
+ };
+ }
+ } catch (err) {
+ console.log('Native barcode detection failed:', err);
+ }
+
+ return { found: false };
+ }
+
+ /**
+ * Try to detect barcode via backend API
+ */
+ async function detectBarcodeBackend(blob) {
+ try {
+ const formData = new FormData();
+ formData.append('image', blob, 'capture.jpg');
+
+ const response = await fetch('/api/barcode', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ return { found: false };
+ }
+
+ const result = await response.json();
+ if (result.found && result.value) {
+ return {
+ found: true,
+ format: result.format || 'unknown',
+ value: result.value
+ };
+ }
+ } catch (err) {
+ console.log('Backend barcode detection failed:', err);
+ }
+
+ return { found: false };
+ }
+
+ /**
+ * Main processing function: Barcode → OCR → Nothing
+ */
+ async function processImage() {
+ if (!state.capturedImage) return;
+
+ hideAllCameraStates();
+ elements.ocrProcessing.style.display = 'flex';
+ updateProcessingStatus('Scanning for barcode...');
+
+ try {
+ const blob = dataURLtoBlob(state.capturedImage);
+
+ // Create an image element for native barcode detection
+ const img = new Image();
+ img.src = state.capturedImage;
+ await new Promise(resolve => img.onload = resolve);
+
+ // Step 1: Try native BarcodeDetector API (Chrome/Edge)
+ let barcodeResult = await detectBarcodeNative(img);
+
+ // Step 2: If native fails, try backend barcode detection
+ if (!barcodeResult || !barcodeResult.found) {
+ barcodeResult = await detectBarcodeBackend(blob);
+ }
+
+ // Step 3: If barcode found, show it
+ if (barcodeResult && barcodeResult.found) {
+ state.scanResult = {
+ type: 'barcode',
+ format: barcodeResult.format,
+ value: barcodeResult.value
+ };
+ showScanResult();
+ return;
+ }
+
+ // Step 4: No barcode, try OCR
+ updateProcessingStatus('Extracting text...');
+
+ const formData = new FormData();
+ formData.append('image', blob, 'capture.jpg');
+
+ const response = await fetch('/api/ocr', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.text && result.text.trim()) {
+ state.scanResult = {
+ type: 'text',
+ value: result.text
+ };
+ showScanResult();
+ return;
+ }
+ }
+
+ // Step 5: Nothing found
+ state.scanResult = {
+ type: 'empty',
+ value: ''
+ };
+ showScanResult();
+
+ } catch (err) {
+ console.error('Image processing error:', err);
+ state.scanResult = {
+ type: 'error',
+ value: 'Could not process image. Please try again.'
+ };
+ showScanResult();
+ }
+ }
+
+ function updateProcessingStatus(message) {
+ const statusEl = elements.ocrProcessing.querySelector('p');
+ if (statusEl) {
+ statusEl.textContent = message;
+ }
+ }
+
+ function showScanResult() {
+ hideAllCameraStates();
+
+ const result = state.scanResult;
+
+ if (result.type === 'barcode') {
+ // Show barcode result
+ elements.ocrHeader.innerHTML = `
+
+
+ Barcode (${formatBarcodeType(result.format)})
+
+
+ `;
+ elements.ocrText.textContent = result.value;
+ elements.ocrText.classList.add('barcode-value');
+
+ } else if (result.type === 'text') {
+ // Show OCR result
+ elements.ocrHeader.innerHTML = `
+
+
+ Extracted Text
+
+
+ `;
+ elements.ocrText.textContent = result.value;
+ elements.ocrText.classList.remove('barcode-value');
+
+ } else if (result.type === 'empty') {
+ elements.ocrHeader.innerHTML = `
+ No content detected
+ `;
+ elements.ocrText.textContent = 'Try adjusting lighting or holding the camera closer to the document.';
+ elements.ocrText.classList.remove('barcode-value');
+
+ } else {
+ // Error
+ elements.ocrHeader.innerHTML = `
+ Error
+ `;
+ elements.ocrText.textContent = result.value;
+ elements.ocrText.classList.remove('barcode-value');
+ }
+
+ elements.ocrText.contentEditable = 'false';
+ elements.ocrResult.style.display = 'block';
+
+ // Re-bind edit button
+ const editBtn = document.getElementById('ocr-edit-btn');
+ if (editBtn) {
+ editBtn.addEventListener('click', toggleOCREdit);
+ }
+
+ updateSubmitButton();
+ }
+
+ function formatBarcodeType(format) {
+ const types = {
+ 'upc_a': 'UPC-A',
+ 'upc_e': 'UPC-E',
+ 'ean_13': 'EAN-13',
+ 'ean_8': 'EAN-8',
+ 'code_128': 'Code 128',
+ 'code_39': 'Code 39',
+ 'code_93': 'Code 93',
+ 'codabar': 'Codabar',
+ 'itf': 'ITF',
+ 'qr_code': 'QR Code',
+ 'data_matrix': 'Data Matrix',
+ 'aztec': 'Aztec',
+ 'pdf417': 'PDF417'
+ };
+ return types[format] || format || 'Unknown';
+ }
+
+ // Legacy function name for backwards compatibility
+ async function processOCR() {
+ return processImage();
+ }
+
+ function toggleOCREdit() {
+ const isEditing = elements.ocrText.contentEditable === 'true';
+ elements.ocrText.contentEditable = isEditing ? 'false' : 'true';
+ if (!isEditing) {
+ elements.ocrText.focus();
+ }
+ }
+
+ function scanAnother() {
+ state.capturedImage = null;
+ state.ocrText = '';
+ startCamera();
+ }
+
+ function useOCRText() {
+ // Get possibly edited text from the contenteditable div
+ const editedValue = elements.ocrText.textContent || '';
+
+ // Update scanResult with edited value
+ if (state.scanResult) {
+ state.scanResult.value = editedValue;
+ }
+ state.ocrText = editedValue;
+
+ // Switch to type mode and populate
+ switchMode('type');
+ elements.textInput.value = editedValue;
+ state.inputValue = editedValue;
+ elements.charCount.textContent = editedValue.length;
+ autoResize(elements.textInput);
+ updateSubmitButton();
+ }
+
+ function hideAllCameraStates() {
+ elements.cameraStart.style.display = 'none';
+ elements.cameraViewfinder.style.display = 'none';
+ elements.photoPreview.style.display = 'none';
+ elements.ocrResult.style.display = 'none';
+ elements.ocrProcessing.style.display = 'none';
+ elements.cameraError.style.display = 'none';
+ }
+
+ function dataURLtoBlob(dataURL) {
+ const arr = dataURL.split(',');
+ const mime = arr[0].match(/:(.*?);/)[1];
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ return new Blob([u8arr], { type: mime });
+ }
+
+ // ========================================
+ // SUBMIT
+ // ========================================
+ function initSubmit() {
+ elements.submitBtn.addEventListener('click', handleSubmit);
+ }
+
+ function updateSubmitButton() {
+ let hasContent = false;
+
+ switch (state.mode) {
+ case 'type':
+ hasContent = state.inputValue.trim().length > 0;
+ break;
+ case 'speak':
+ hasContent = state.transcript.trim().length > 0;
+ break;
+ case 'scan':
+ hasContent = state.scanResult &&
+ state.scanResult.value &&
+ state.scanResult.value.trim().length > 0 &&
+ state.scanResult.type !== 'empty' &&
+ state.scanResult.type !== 'error';
+ break;
+ }
+
+ elements.submitBtn.disabled = !hasContent;
+ }
+
+ function handleSubmit() {
+ let content = '';
+ let scanType = null;
+ let scanFormat = null;
+
+ switch (state.mode) {
+ case 'type':
+ content = state.inputValue.trim();
+ break;
+ case 'speak':
+ content = state.transcript.trim();
+ break;
+ case 'scan':
+ if (state.scanResult) {
+ content = (elements.ocrText.textContent || state.scanResult.value || '').trim();
+ scanType = state.scanResult.type;
+ scanFormat = state.scanResult.format || null;
+ }
+ break;
+ }
+
+ if (!content) return;
+
+ // Build payload
+ const payload = {
+ mode: state.mode,
+ content: content,
+ timestamp: new Date().toISOString()
+ };
+
+ // Add barcode-specific fields
+ if (state.mode === 'scan' && scanType === 'barcode') {
+ payload.scanType = 'barcode';
+ payload.barcodeFormat = scanFormat;
+ payload.barcodeValue = content;
+ } else if (state.mode === 'scan') {
+ payload.scanType = 'text';
+ }
+
+ // Dispatch custom event for Flutter WebView or parent frame
+ const event = new CustomEvent('inou-input-submit', { detail: payload });
+ window.dispatchEvent(event);
+
+ // Also try postMessage for WebView communication
+ if (window.flutter_inappwebview) {
+ window.flutter_inappwebview.callHandler('onInputSubmit', payload);
+ } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.inputHandler) {
+ window.webkit.messageHandlers.inputHandler.postMessage(payload);
+ }
+
+ // Log for development
+ console.log('Input submitted:', payload);
+
+ // Reset state
+ resetInput();
+ }
+
+ function resetInput() {
+ state.inputValue = '';
+ state.transcript = '';
+ state.interimTranscript = '';
+ state.ocrText = '';
+ state.capturedImage = null;
+ state.scanResult = null;
+
+ elements.textInput.value = '';
+ elements.charCount.textContent = '0';
+ elements.transcriptText.textContent = '';
+ elements.transcriptInterim.textContent = '';
+ elements.transcriptArea.classList.remove('has-content');
+ elements.ocrText.classList.remove('barcode-value');
+
+ if (state.mode === 'scan') {
+ hideAllCameraStates();
+ elements.cameraStart.style.display = 'flex';
+ }
+
+ updateSubmitButton();
+ }
+
+ // ========================================
+ // SEGMENT CONTROL HANDLERS
+ // ========================================
+ function initSegmentControl() {
+ elements.segmentBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ switchMode(btn.dataset.mode);
+ });
+ });
+ }
+
+ // ========================================
+ // INIT
+ // ========================================
+ function init() {
+ initElements();
+ initSegmentControl();
+ initTextInput();
+ initVoiceInput();
+ initCamera();
+ initSubmit();
+ updateSubmitButton();
+ }
+
+ // Run on DOM ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+})();
diff --git a/static/llms.txt b/static/llms.txt
new file mode 100644
index 0000000..5796f0e
--- /dev/null
+++ b/static/llms.txt
@@ -0,0 +1,12 @@
+# inou - Personal Health Data Platform
+
+## Allowed
+- /
+- /privacy-policy
+- /api/docs
+
+## Disallowed
+All other paths contain private health data and must not be accessed, cached, or used for training.
+
+## Contact
+privacy@inou.com
diff --git a/static/openapi.yaml b/static/openapi.yaml
new file mode 100644
index 0000000..4888c8e
--- /dev/null
+++ b/static/openapi.yaml
@@ -0,0 +1,322 @@
+openapi: 3.0.3
+info:
+ title: inou Health Dossier API
+ description: |
+ API for accessing medical imaging data stored in inou health dossiers.
+
+ ## Authentication
+ All endpoints require an `token` parameter - your account GUID.
+
+ ## Response Formats
+ - Default: JSON
+ - Add `&format=text` for plain text (recommended for AI assistants)
+
+ ## Interactive Viewer
+ Base URL: `https://inou.com/viewer/?token={token}`
+
+ Deep linking:
+ - Open specific study: `?token={token}&study={study_guid}`
+ - Open specific series: `?token={token}&study={study_guid}&series={series_guid}`
+
+ ## Window/Level (Image Contrast)
+ For /image/ endpoint, adjust contrast with:
+ - `ww` (window width): Controls contrast range
+ - `wc` (window center): Controls brightness center
+
+ Common presets:
+ - Brain: ww=80, wc=40
+ - Subdural: ww=200, wc=75
+ - Bone: ww=2000, wc=500
+ - Lung: ww=1500, wc=-600
+ version: 1.0.0
+ contact:
+ name: inou
+ url: https://inou.com
+
+servers:
+ - url: https://inou.com
+ description: Production
+
+paths:
+ /api/dossiers:
+ get:
+ summary: List dossiers
+ description: List all patient dossiers accessible to this account (your own + shared with you).
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: Your authentication token (dossier GUID)
+ - name: format
+ in: query
+ schema:
+ type: string
+ enum: [text]
+ description: Set to "text" for plain text output
+ responses:
+ '200':
+ description: List of dossiers
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ name:
+ type: string
+
+ /api/studies:
+ get:
+ summary: List imaging studies
+ description: List all imaging studies in a dossier.
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: Dossier GUID
+ - name: format
+ in: query
+ schema:
+ type: string
+ enum: [text]
+ responses:
+ '200':
+ description: List of studies
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ patient_name:
+ type: string
+ study_date:
+ type: string
+ study_desc:
+ type: string
+ series_count:
+ type: integer
+
+ /api/series:
+ get:
+ summary: List series in a study
+ description: List all series for a study. Filter by description (AX, T1, FLAIR, SAG, COR, etc).
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: study_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: filter
+ in: query
+ schema:
+ type: string
+ description: Filter by series description (e.g., "T1", "FLAIR", "SAG")
+ - name: format
+ in: query
+ schema:
+ type: string
+ enum: [text]
+ responses:
+ '200':
+ description: List of series
+
+ /api/slices:
+ get:
+ summary: List slices in a series
+ description: List all slices with position data (mm coordinates, orientation, pixel spacing).
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: series_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: format
+ in: query
+ schema:
+ type: string
+ enum: [text]
+ responses:
+ '200':
+ description: List of slices with position info
+
+
+ /image/{slice_guid}:
+ get:
+ summary: Get slice image
+ description: Fetch a slice as PNG image. Adjust window/level for contrast.
+ parameters:
+ - name: slice_guid
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: ww
+ in: query
+ schema:
+ type: number
+ description: Window width (contrast range). Brain=80, Bone=2000, Lung=1500
+ - name: wc
+ in: query
+ schema:
+ type: number
+ description: Window center (brightness). Brain=40, Bone=500, Lung=-600
+ responses:
+ '200':
+ description: PNG image
+ content:
+ image/png:
+ schema:
+ type: string
+ format: binary
+
+ /viewer/:
+ get:
+ summary: Interactive DICOM viewer
+ description: Open the web-based DICOM viewer with 3D crosshair navigation.
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: Dossier GUID
+ - name: study
+ in: query
+ schema:
+ type: string
+ format: uuid
+ description: Open specific study
+ - name: series
+ in: query
+ schema:
+ type: string
+ format: uuid
+ description: Open specific series (requires study parameter)
+ responses:
+ '200':
+ description: HTML viewer page
+
+ /api/labs/tests:
+ get:
+ summary: List lab test names
+ description: List all lab test names available for a dossier.
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: List of test names
+
+ /api/labs/results:
+ get:
+ summary: Get lab results
+ description: Get lab results by test name, with optional date filtering.
+ parameters:
+ - name: token
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: dossier_guid
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: names
+ in: query
+ required: true
+ schema:
+ type: string
+ description: Comma-separated test names
+ - name: from
+ in: query
+ schema:
+ type: string
+ format: date
+ description: Start date (YYYY-MM-DD)
+ - name: to
+ in: query
+ schema:
+ type: string
+ format: date
+ description: End date (YYYY-MM-DD)
+ - name: latest
+ in: query
+ schema:
+ type: boolean
+ description: Return only most recent result per test
+ responses:
+ '200':
+ description: Lab results
diff --git a/static/pricing.html b/static/pricing.html
new file mode 100644
index 0000000..7010c40
--- /dev/null
+++ b/static/pricing.html
@@ -0,0 +1,259 @@
+
+
+
+
+
+ inou health - Pricing
+
+
+
+
+
inou health
+
AI answers for you
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ | Health Data |
+
+
+ | Vitals (BP, HR, weight, temp) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Symptoms & conditions |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Medications |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Exercise & activity |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Family history |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Lab results |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+ | Consumer genome (23andMe) |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+ | Medical imaging (MRI, CT, X-ray) |
+ ✗ |
+ ✗ |
+ ✓ |
+
+
+ | Clinical genome sequencing |
+ ✗ |
+ ✗ |
+ ✓ |
+
+
+
+ | AI Features |
+
+
+ | MCP integration (Claude, ChatGPT) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Personalized AI answers |
+ Limited |
+ ✓ |
+ ✓ |
+
+
+ | Health trend analysis |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+
+ | Storage & Access |
+
+
+ | Multi-dossier support (family) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | FIPS 140-3 encryption |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Data export |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+
+
+
+
+
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..1f8237f
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,16 @@
+User-agent: *
+Allow: /
+Allow: /privacy-policy
+Allow: /connect
+Disallow: /dossier
+Disallow: /dashboard
+Disallow: /share
+Disallow: /invite
+Disallow: /onboard
+Disallow: /verify
+Disallow: /start
+Disallow: /login
+Disallow: /set-lang
+Disallow: /api/
+
+Sitemap: https://inou.com/sitemap.xml
diff --git a/static/sitemap.xml b/static/sitemap.xml
new file mode 100644
index 0000000..4b482fa
--- /dev/null
+++ b/static/sitemap.xml
@@ -0,0 +1,18 @@
+
+
+
+ https://inou.com/
+ weekly
+ 1.0
+
+
+ https://inou.com/privacy-policy
+ monthly
+ 0.5
+
+
+ https://inou.com/connect
+ monthly
+ 0.7
+
+
diff --git a/static/slice12_thumb.png b/static/slice12_thumb.png
new file mode 100644
index 0000000..2bd771d
Binary files /dev/null and b/static/slice12_thumb.png differ
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..775787e
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,1864 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+:root {
+ --bg: #F8F7F6;
+ --bg-card: #FFFFFF;
+ --border: #E5E2DE;
+ --border-hover: #C4BFB8;
+ --text: #1C1917;
+ --text-muted: #78716C;
+ --text-subtle: #A8A29E;
+ --accent: #B45309;
+ --accent-hover: #92400E;
+ --accent-light: #FEF3C7;
+ --danger: #DC2626;
+ --danger-light: #FEF2F2;
+ --success: #059669;
+ --success-light: #ECFDF5;
+}
+
+body {
+ font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-weight: 400;
+ line-height: 1.5;
+ font-size: 15px;
+}
+
+/* Navigation */
+.nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 12px 24px;
+ max-width: 1200px;
+ margin: 0 auto;
+ border-bottom: 1px solid var(--border);
+}
+
+.logo {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-family: "Sora", sans-serif;
+ font-size: 1.75rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ text-transform: lowercase;
+ text-decoration: none;
+}
+.logo:hover { text-decoration: none; }
+.logo .inou { color: var(--accent); font-weight: 700; }
+.logo .health { color: var(--text-muted); font-weight: 300; }
+.logo .logo-tagline {
+ font-size: 0.95rem;
+ font-weight: 300;
+ color: var(--text-muted);
+ letter-spacing: 0.04em;
+ text-transform: none;
+}
+
+.nav-right {
+ display: flex;
+ align-items: baseline;
+ gap: 16px;
+}
+
+/* User menu with hover dropdown */
+.nav-user-menu {
+ position: relative;
+}
+
+.nav-user-name {
+ font-size: 0.85rem;
+ color: var(--text);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+
+.nav-user-name:hover {
+ background: var(--border);
+}
+
+.nav-user-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ padding: 4px;
+ min-width: 100px;
+ z-index: 100;
+}
+
+.nav-user-menu:hover .nav-user-dropdown {
+ display: block;
+
+}
+
+.nav-user-dropdown a {
+ display: block;
+
+ padding: 8px 12px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.85rem;
+ border-radius: 4px;
+}
+
+.nav-user-dropdown a:hover {
+ background: var(--bg);
+ color: var(--accent);
+}
+
+/* Language menu */
+.lang-menu {
+ position: relative;
+}
+
+.lang-current {
+ min-width: 36px;
+ display: inline-block;
+ text-align: center;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+.lang-current:hover {
+ border-color: var(--border-hover);
+}
+
+.lang-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ padding: 4px;
+ min-width: 120px;
+ white-space: nowrap;
+ z-index: 100;
+ padding-top: 8px;
+ margin-top: 0;
+}
+
+.lang-menu::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ height: 8px;
+}
+
+.lang-menu:hover .lang-dropdown {
+ display: block;
+
+}
+
+.lang-dropdown a {
+ display: block;
+
+ padding: 6px 12px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.85rem;
+ border-radius: 4px;
+}
+
+.lang-dropdown a:hover {
+ background: var(--bg);
+}
+
+.lang-dropdown a.active {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.nav-user {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.nav-user a {
+ color: var(--text);
+ text-decoration: none;
+}
+
+.nav-user a:hover {
+ color: var(--accent);
+}
+
+.lang-picker {
+ font-size: 1rem;
+ color: var(--text-muted);
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+/* Container */
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 40px 20px;
+}
+
+.container-narrow {
+ max-width: 360px;
+ margin: 0 auto;
+ padding: 60px 20px 40px;
+}
+
+/* Dossier header */
+.dossier-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 24px;
+}
+
+.dossier-header-left h1 {
+ margin-bottom: 0;
+}
+
+.dossier-header-left p {
+ margin: 4px 0 0 0;
+}
+
+/* Coming soon badge */
+.badge-soon {
+ font-size: 0.7rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+
+.data-card.coming-soon {
+ opacity: 0.6;
+}
+
+/* Typography */
+h1 {
+ font-size: 2.25rem;
+ font-weight: 300;
+ line-height: 1.2;
+ margin-bottom: 16px;
+ letter-spacing: -0.03em;
+ color: var(--text);
+}
+
+h1.small {
+ font-size: 1.5rem;
+ font-weight: 300;
+ margin-bottom: 4px;
+}
+
+h2 {
+ font-size: 1.5rem;
+ font-weight: 300;
+ margin-bottom: 12px;
+ letter-spacing: -0.02em;
+}
+
+h3 {
+ font-size: 1.125rem;
+ font-weight: 500;
+ margin-bottom: 4px;
+ color: var(--text);
+}
+
+.intro {
+ font-size: 1rem;
+ color: var(--text-muted);
+ margin-bottom: 32px;
+}
+
+.intro.small {
+ font-size: 1rem;
+ margin-bottom: 24px;
+}
+
+.section-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-subtle);
+ margin-bottom: 12px;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 1rem;
+ font-family: inherit;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+ color: var(--text);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.form-group select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ padding-right: 36px;
+ cursor: pointer;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-light);
+}
+
+.form-group input.code-input {
+ font-size: 1.375rem;
+ text-align: center;
+ letter-spacing: 0.4em;
+ font-weight: 500;
+ font-family: "SF Mono", "Monaco", monospace;
+}
+
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.radio-group {
+ display: flex;
+ gap: 16px;
+}
+
+.radio-group label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 1rem;
+ color: var(--text);
+ cursor: pointer;
+ font-weight: 400;
+}
+
+.radio-group input {
+ width: auto;
+ accent-color: var(--accent);
+}
+
+.checkbox-group label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 1rem;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.checkbox-group input {
+ width: auto;
+ accent-color: var(--accent);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 18px;
+ font-size: 1rem;
+ font-weight: 500;
+ font-family: inherit;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: all 0.15s;
+ border: none;
+ cursor: pointer;
+ text-align: center;
+ gap: 6px;
+}
+
+.btn-full {
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #FFFFFF;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-card);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ border-color: var(--border-hover);
+ background: var(--bg);
+}
+
+.btn-danger {
+ background: var(--danger-light);
+ color: var(--danger);
+ border: 1px solid #FECACA;
+}
+
+.btn-danger:hover {
+ background: #FEE2E2;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 1rem;
+}
+
+.btn-disabled {
+ background: var(--bg-subtle);
+ color: var(--text-subtle);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.btn-icon {
+ padding: 4px 8px;
+ background: transparent;
+ color: var(--text-subtle);
+ border: none;
+ font-size: 1rem;
+ line-height: 1;
+ border-radius: 4px;
+}
+
+.btn-icon:hover {
+ color: var(--danger);
+ background: var(--danger-light);
+}
+
+/* Cards */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.card-link {
+
+ text-decoration: none;
+ color: inherit;
+ padding: 0;
+ transition: all 0.15s;
+}
+
+.card-link:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+ text-decoration: none;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.card-header h3 {
+ margin-bottom: 0;
+}
+
+.card-meta {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+.card-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.card-add {
+ border: 2px dashed var(--border);
+ background: transparent;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.card-add:hover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.card-add .plus {
+ font-size: 1.75rem;
+ color: var(--accent);
+ margin-bottom: 6px;
+}
+
+.card-add span {
+ color: var(--text-muted);
+ font-size: 1rem;
+}
+
+/* Profiles grid */
+.profiles-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 12px;
+}
+
+/* Profile card hover */
+.profile-card {
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.profile-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+/* Profile badge */
+.badge {
+ display: inline-block;
+ padding: 2px 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ border-radius: 4px;
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.badge-care {
+ background: var(--success-light);
+ color: var(--success);
+}
+
+/* Access list */
+.access-list {
+ margin-top: 8px;
+}
+
+.access-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.access-item:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.access-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.access-name {
+ font-weight: 500;
+ font-size: 1rem;
+}
+
+.access-relation {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+/* Messages */
+.error {
+ background: var(--danger-light);
+ border: 1px solid #FECACA;
+ color: var(--danger);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+.info {
+ background: var(--accent-light);
+ border: 1px solid #FDE68A;
+ color: var(--accent);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+.success {
+ background: var(--success-light);
+ border: 1px solid #A7F3D0;
+ color: var(--success);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+/* Trust section */
+.trust {
+ border-top: 1px solid var(--border);
+ padding-top: 32px;
+ margin-top: 32px;
+}
+
+.trust-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 20px;
+}
+
+.trust-item {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+.trust-item strong {
+
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 2px;
+}
+
+/* Footer */
+.footer {
+ margin-top: 40px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+/* Upload area */
+.upload-area {
+ border: 2px dashed var(--border);
+ border-radius: 8px;
+ padding: 40px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.15s;
+ background: var(--bg-card);
+}
+
+.upload-area:hover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.upload-area.dragover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.upload-icon {
+ color: var(--accent);
+ margin-bottom: 12px;
+}
+
+.upload-text {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+
+.upload-hint {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+/* Progress */
+.progress-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.progress-modal {
+ background: var(--bg-card);
+ padding: 32px;
+ border-radius: 12px;
+ text-align: center;
+ min-width: 280px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
+}
+
+.progress-bar-wrap {
+ background: var(--border);
+ border-radius: 4px;
+ height: 6px;
+ overflow: hidden;
+ margin-top: 16px;
+}
+
+.progress-bar {
+ background: var(--accent);
+ height: 100%;
+ width: 0%;
+ transition: width 0.2s;
+}
+
+.progress-detail {
+ margin-top: 12px;
+ font-size: 1rem;
+ color: var(--text-muted);
+ max-width: 320px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* File table */
+.file-table {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.file-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ font-size: 1rem;
+}
+
+.file-row:last-child {
+ border-bottom: none;
+}
+
+.file-row.file-deleted {
+ background: var(--bg);
+ color: var(--text-subtle);
+}
+
+.file-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.file-name {
+ color: var(--text);
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-deleted .file-name {
+ color: var(--text-subtle);
+ text-decoration: line-through;
+}
+
+.file-meta {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+.file-status {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.status-expires {
+ color: var(--text-muted);
+}
+
+.status-deleted {
+ color: var(--text-subtle);
+ font-style: italic;
+}
+
+/* Link */
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Helpers */
+.text-center { text-align: center; }
+.text-muted { color: var(--text-muted); }
+.text-small { font-size: 1rem; }
+.mt-8 { margin-top: 8px; }
+.mt-16 { margin-top: 16px; }
+.mt-24 { margin-top: 24px; }
+.mb-8 { margin-bottom: 0; }
+.mb-16 { margin-bottom: 16px; }
+.mb-24 { margin-bottom: 24px; }
+
+/* Relation cards */
+.relation-cards {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.relation-card {
+
+ padding: 10px 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.15s;
+ font-size: 1rem;
+ background: var(--bg-card);
+}
+
+.relation-card:hover {
+ border-color: var(--accent);
+}
+
+.relation-card input {
+ display: none;
+}
+
+.relation-card input:checked + span {
+ font-weight: 600;
+}
+
+.relation-card:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.radio-pill {
+ display: inline-block;
+ padding: 6px 14px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ cursor: pointer;
+ margin-right: 4px;
+ transition: all 0.15s;
+ font-size: 1rem;
+ background: var(--bg-card);
+}
+
+.radio-pill:hover {
+ border-color: var(--accent);
+}
+
+.radio-pill input {
+ display: none;
+}
+
+.radio-pill:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+/* Data cards on profile page */
+.data-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 18px;
+ cursor: pointer;
+}
+
+.data-section-info h3 {
+ margin-bottom: 2px;
+}
+
+.data-section-meta {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+.data-section-arrow {
+ color: var(--accent);
+ font-size: 1.375rem;
+ font-weight: 500;
+}
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--text-muted);
+ font-size: 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+
+/* Category inline selector */
+.category-inline {
+ font-size: 0.8rem;
+ padding: 0.15rem 0.4rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text-muted);
+ cursor: pointer;
+}
+.category-inline:hover {
+ border-color: var(--accent);
+}
+.category-inline:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-light);
+}
+
+/* Status badge */
+.status-badge {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.4rem;
+ background: var(--accent-light);
+ color: var(--accent);
+ border-radius: 4px;
+ margin-right: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+/* Form select for category */
+.form-select {
+
+ width: 100%;
+ padding: 0.6rem 0.8rem;
+ font-size: 0.95rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+ color: var(--text);
+ cursor: pointer;
+}
+.form-select:hover {
+ border-color: var(--border-hover);
+}
+.form-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-light);
+}
+
+
+/* Data Cards - Andrew McCalip inspired */
+.data-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ overflow: hidden;
+}
+
+.data-card-header {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+ gap: 12px;
+}
+
+.data-card-indicator {
+ width: 4px;
+ height: 32px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.data-card-indicator.imaging { background: var(--accent); }
+.data-card-indicator.labs { background: #059669; }
+.data-card-indicator.uploads { background: #6366f1; }
+.data-card-indicator.vitals { background: #ec4899; }
+.data-card-indicator.medications { background: #8b5cf6; }
+.data-card-indicator.records { background: #06b6d4; }
+.data-card-indicator.journal { background: #f59e0b; }
+.data-card-indicator.privacy { background: #64748b; }
+
+.data-card-title {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.data-card-summary {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+/* Data Table */
+.data-table {
+ border-top: 1px solid var(--border);
+}
+
+.data-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px dashed var(--border);
+ gap: 16px;
+}
+
+.data-row:last-child {
+ border-bottom: none;
+}
+
+.data-row.expandable {
+ cursor: pointer;
+}
+
+.data-row.expandable:hover {
+ background: var(--bg);
+}
+
+.data-row-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.expand-icon {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-mono, monospace);
+ font-size: 14px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.data-row.expanded .expand-icon {
+ transform: rotate(45deg);
+}
+
+.data-row.single .data-row-main {
+ padding-left: 32px;
+}
+
+.data-label {
+ font-weight: 500;
+ color: var(--text);
+}
+
+.data-meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.data-values {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.data-value {
+ font-size: 0.85rem;
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.data-value.mono {
+ font-family: "SF Mono", "Monaco", "Consolas", monospace;
+ font-size: 0.8rem;
+}
+
+.data-date {
+ font-family: "SF Mono", "Monaco", "Consolas", monospace;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ min-width: 80px;
+ text-align: right;
+}
+
+/* Expandable children */
+.data-row-children {
+ display: none;
+ background: var(--bg);
+ border-top: 1px solid var(--border);
+}
+
+.data-row-children.show {
+ display: block;
+
+}
+
+.data-row.child {
+ padding-left: 48px;
+ border-bottom: 1px dashed var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+
+.data-row.child:last-child {
+ border-bottom: none;
+}
+
+.data-row.child .data-label {
+ font-weight: 400;
+ font-size: 0.9rem;
+ flex: 1;
+}
+
+/* Section heading - smaller, uppercase */
+.section-heading {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text);
+}
+
+/* Utility */
+.p-16 { padding: 16px; }
+
+/* Privacy actions row */
+.privacy-actions {
+ display: flex;
+ gap: 24px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.privacy-action {
+ font-size: 0.85rem;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.privacy-action:hover {
+ text-decoration: underline;
+}
+
+/* Share form */
+.share-form {
+ padding: 24px;
+}
+
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.form-row:last-child {
+ margin-bottom: 0;
+ margin-top: 24px;
+}
+
+.form-row > label {
+ flex: 0 0 180px;
+
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 0;
+}
+
+.form-input,
+.form-select {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 1rem;
+ font-family: inherit;
+ color: var(--text);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.form-input:focus,
+.form-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1);
+}
+
+.form-input::placeholder {
+ color: var(--text-subtle);
+}
+
+/* Custom select wrapper */
+.select-wrapper {
+ position: relative;
+}
+
+.select-wrapper::after {
+ content: '';
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid var(--text-muted);
+ pointer-events: none;
+}
+
+.form-select {
+ appearance: none;
+ -webkit-appearance: none;
+ cursor: pointer;
+ padding-right: 40px;
+}
+
+/* Checkbox styling */
+.checkbox-label {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ cursor: pointer;
+ font-weight: 400 !important;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ accent-color: var(--accent);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.checkbox-label span {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+/* Form row flex children */
+.form-row .form-input,
+.form-row .select-wrapper {
+ flex: 1;
+}
+
+.form-row .select-wrapper .form-select {
+ width: 100%;
+}
+
+/* Install page */
+.install-content {
+ padding: 16px 24px 24px;
+}
+
+.install-content p {
+ margin: 0 0 16px;
+ color: var(--text-muted);
+}
+
+.download-buttons {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.code-block {
+ background: #1e1e1e;
+ color: #d4d4d4;
+ padding: 16px;
+ border-radius: 8px;
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+ font-size: 0.85rem;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+.form-spacer {
+ flex: 0 0 180px;
+}
+
+/* Nav links */
+.nav-link {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ text-decoration: none;
+}
+
+.nav-link:hover {
+ color: var(--text);
+ text-decoration: none;
+}
+
+
+/* Privacy page */
+.privacy-container {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 60px 40px;
+}
+
+.privacy-container h1 {
+ font-family: Sora, sans-serif;
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: #2C1810;
+ margin-bottom: 16px;
+}
+
+.privacy-container .intro {
+ font-family: Sora, sans-serif;
+ font-size: 1.15rem;
+ font-weight: 300;
+ color: #4A3728;
+ line-height: 1.8;
+ margin-bottom: 48px;
+}
+
+.privacy-container h2 {
+ font-family: Sora, sans-serif;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: #2C1810;
+ margin-top: 48px;
+ margin-bottom: 24px;
+}
+
+.privacy-container h3 {
+ font-family: Sora, sans-serif;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #2C1810;
+ margin-top: 24px;
+ margin-bottom: 8px;
+}
+
+.privacy-container p {
+ font-family: Sora, sans-serif;
+ font-size: 1rem;
+ font-weight: 300;
+ color: #4A3728;
+ line-height: 1.8;
+ margin-bottom: 16px;
+}
+
+.privacy-container strong {
+ font-weight: 600;
+ color: #2C1810;
+}
+
+.privacy-container a {
+ color: #B45309;
+}
+
+.privacy-container .legal-section {
+ margin-top: 48px;
+ padding-top: 32px;
+ border-top: 1px solid #E5DDD3;
+}
+
+.inou-brand {
+ font-weight: 700;
+ color: #B45309;
+}
+/* Base styles */
+.sg-container { max-width: 1200px; margin: 0 auto; padding: 48px 24px 80px; }
+.sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; }
+.sg-card-content { padding: 32px; }
+.sg-card-content-sm { padding: 24px; max-width: 480px; }
+.sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; }
+.sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; }
+.sg-profile-card .card-meta { margin-bottom: 8px; }
+.sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; }
+.sg-profile-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 12px; }
+.sg-profile-stat { display: flex; align-items: center; gap: 4px; }
+.sg-row-link { color: var(--accent); font-size: 1.1rem; text-decoration: none; padding: 4px 8px; border-radius: 4px; }
+.sg-row-link:hover { background: var(--accent-light); }
+.sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); }
+.sg-supp-timing { font-size: 0.8rem; color: var(--text-subtle); }
+.sg-supp-amount { font-size: 0.8rem; color: var(--text-muted); margin-left: 8px; }
+.sg-footer { margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
+.sg-footer-left { font-size: 0.9rem; color: var(--text-muted); display: flex; gap: 16px; align-items: center; }
+.sg-footer-left a { color: var(--text-muted); text-decoration: none; }
+.sg-footer-left a:hover { color: var(--accent); }
+.sg-footer-right { font-family: "Sora", sans-serif; font-size: 1rem; }
+.sg-footer-right .inou { font-weight: 700; color: var(--accent); }
+.sg-footer-right .health { font-weight: 400; color: var(--text-muted); }
+.sg-select { width: 100%; padding: 10px 12px; font-size: 1rem; font-family: inherit; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; cursor: pointer; }
+.sg-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
+.badge, .badge-care, .badge-soon, .status-badge { font-family: "Sora", sans-serif; }
+.sg-gene-row { display: flex; flex-direction: column; gap: 4px; }
+.sg-gene-main { display: flex; align-items: center; gap: 8px; }
+.sg-gene-name { font-weight: 600; }
+.sg-gene-rsid { font-size: 0.8rem; color: var(--text-muted); font-family: "SF Mono", Monaco, monospace; }
+.sg-gene-allele { font-family: "SF Mono", Monaco, monospace; font-size: 0.95rem; font-weight: 600; background: var(--bg); padding: 2px 8px; border-radius: 4px; }
+.sg-gene-summary { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-top: 4px; }
+.sg-gene-actions { display: flex; gap: 8px; margin-top: 8px; }
+.sg-ask-ai { font-size: 0.75rem; padding: 4px 10px; background: var(--accent-light); color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-family: "Sora", sans-serif; font-weight: 500; }
+.sg-ask-ai:hover { background: var(--accent); color: white; }
+.sg-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; padding: 16px; }
+.sg-modal-overlay.show { display: flex; }
+.sg-modal { background: var(--bg-card); border-radius: 12px; padding: 24px; max-width: 560px; width: 100%; box-shadow: 0 20px 40px rgba(0,0,0,0.2); }
+.sg-modal h3 { margin-bottom: 16px; }
+.sg-modal-prompt { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; line-height: 1.6; margin-bottom: 16px; white-space: pre-wrap; }
+.sg-modal-actions { display: flex; gap: 12px; justify-content: flex-end; }
+.sg-settings-row { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--border); gap: 24px; }
+.sg-settings-row:last-child { border-bottom: none; }
+.sg-settings-label { font-weight: 500; }
+.sg-settings-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 2px; }
+.sg-settings-control { min-width: 200px; }
+.sg-llm-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; margin-bottom: 8px; }
+.sg-llm-option:hover { border-color: var(--accent); }
+.sg-llm-option.selected { border-color: var(--accent); background: var(--accent-light); }
+.sg-llm-option input { display: none; }
+.sg-llm-icon { width: 24px; height: 24px; border-radius: 4px; background: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 0.8rem; }
+.sg-show-more { padding: 12px 16px; text-align: center; color: var(--accent); font-size: 0.85rem; cursor: pointer; border-top: 1px solid var(--border); }
+.sg-show-more:hover { background: var(--accent-light); }
+.sg-vital-history { padding: 12px 16px 12px 48px; background: var(--bg); border-top: 1px solid var(--border); }
+.sg-vital-entry { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--border); }
+.sg-vital-entry:last-child { border-bottom: none; }
+.sg-vital-date { font-size: 0.8rem; color: var(--text-muted); }
+.sg-vital-val { font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; }
+.sg-vital-graph { height: 60px; display: flex; align-items: flex-end; gap: 4px; padding: 8px 0; }
+.sg-vital-bar { width: 24px; background: var(--accent); border-radius: 3px 3px 0 0; opacity: 0.7; }
+.sg-vital-bar:last-child { opacity: 1; }
+.sg-note-detail { padding: 16px; background: var(--bg); border-top: 1px solid var(--border); }
+.sg-note-photos { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
+.sg-note-photo-item { text-align: center; }
+.sg-note-photo-img { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); background: #E5E2DE; display: flex; align-items: center; justify-content: center; font-size: 2rem; }
+.sg-note-photo-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; }
+.sg-note-timeline { margin-top: 12px; }
+.sg-note-timeline-entry { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px dashed var(--border); }
+.sg-note-timeline-entry:last-child { border-bottom: none; }
+.sg-note-timeline-date { font-size: 0.8rem; color: var(--text-muted); min-width: 80px; }
+.sg-note-timeline-text { font-size: 0.9rem; color: var(--text); }
+.sg-note-category { font-size: 0.75rem; color: var(--text-subtle); background: var(--bg); padding: 2px 6px; border-radius: 3px; margin-left: 8px; }
+.sg-note-icon { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }
+.sg-note-icon.temp { background: #FEE2E2; color: #DC2626; }
+.sg-note-icon.weight { background: #DBEAFE; color: #2563EB; }
+.sg-note-icon.bp { background: #FCE7F3; color: #DB2777; }
+.sg-note-icon.note { background: var(--accent-light); color: var(--accent); }
+.sg-note-icon.photo { background: #E0E7FF; color: #4F46E5; }
+.sg-peptide-dates { font-size: 0.8rem; color: var(--text-muted); }
+.sg-peptide-history { font-size: 0.75rem; color: var(--text-subtle); margin-top: 2px; }
+
+/* ========================================
+ MOBILE RESPONSIVE STYLES
+ ======================================== */
+
+/* Tablet and below */
+@media (max-width: 768px) {
+ .sg-container { padding: 24px 16px 60px; }
+ .sg-card-content { padding: 20px; }
+ .sg-card-content-sm { padding: 16px; max-width: 100%; }
+
+ /* Settings row stacks */
+ .sg-settings-row { flex-direction: column; align-items: flex-start; gap: 12px; }
+ .sg-settings-control { min-width: 100%; width: 100%; }
+
+ /* Profile grid single column */
+ .profiles-grid { grid-template-columns: 1fr !important; }
+
+ /* Data card indicators grid */
+ .sg-indicators-grid { grid-template-columns: repeat(2, 1fr) !important; }
+
+ /* Gene row layout */
+ .sg-gene-row > div:first-child { flex-direction: column; align-items: flex-start !important; gap: 8px; }
+
+ /* Vital history less padding */
+ .sg-vital-history { padding-left: 16px; }
+}
+
+/* Phone portrait */
+@media (max-width: 480px) {
+ .sg-container { padding: 16px 12px 48px; }
+ .sg-container > h1 { font-size: 2rem; }
+ .sg-container > .intro { font-size: 1rem; }
+
+ /* Data rows stack */
+ .data-row:not(.child) { flex-direction: column; align-items: flex-start !important; gap: 8px; padding: 12px; }
+ .data-row .data-values { width: 100%; justify-content: flex-start; gap: 12px; }
+ .data-row .data-row-main { width: 100%; }
+
+ /* Child rows stay horizontal but tighter */
+ .data-row.child { padding: 10px 12px 10px 24px; }
+ .data-row.child .data-values { gap: 8px; }
+
+ /* Buttons wrap */
+ .sg-card-content > div[style*="flex-wrap"] { gap: 8px; }
+
+ /* Modal full width */
+ .sg-modal { padding: 16px; border-radius: 8px; }
+ .sg-modal-prompt { font-size: 0.8rem; padding: 12px; }
+ .sg-modal-actions { flex-direction: column; }
+ .sg-modal-actions .btn { width: 100%; }
+
+ /* Footer stacks */
+ .sg-footer { flex-direction: column; gap: 12px; align-items: center; text-align: center; }
+ .sg-footer-left { flex-direction: column; gap: 8px; }
+
+ /* Typography scale values hide on mobile */
+ .data-card:nth-child(2) .data-values { display: none; }
+
+ /* Profile cards tighter */
+ .sg-profile-card { padding: 16px; min-height: auto; }
+ .sg-profile-stats { flex-wrap: wrap; gap: 8px; }
+
+ /* LLM options full width */
+ .sg-llm-option { padding: 12px; }
+
+ /* Photos smaller */
+ .sg-note-photo-img { width: 64px; height: 64px; font-size: 1.5rem; }
+
+ /* Timeline entry stacks */
+ .sg-note-timeline-entry { flex-direction: column; gap: 4px; }
+ .sg-note-timeline-date { min-width: auto; }
+
+ /* Gene allele and badge wrap */
+ .sg-gene-row > div:first-child > div:last-child { flex-wrap: wrap; }
+}
+
+/* Extra small phones */
+@media (max-width: 360px) {
+ .sg-container { padding: 12px 8px 40px; }
+ .data-row { padding: 10px 8px; }
+ .sg-note-photos { gap: 8px; }
+ .sg-note-photo-img { width: 56px; height: 56px; }
+}
+
+/* Sticky footer - push to bottom when content is short */
+.sg-container {
+ min-height: calc(100vh - 48px);
+ padding-bottom: 24px !important;
+ display: flex;
+ flex-direction: column;
+}
+
+.sg-container > .sg-footer {
+ margin-top: auto;
+}
+
+.sg-container.sticky-footer {
+ padding-bottom: 24px;
+}
+
+.sg-footer {
+ padding-top: 24px;
+}
+
+/* Modal */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 24px;
+}
+.modal-content {
+ background: #fff;
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+ max-height: 90vh;
+ overflow-y: auto;
+}
+.modal-content h3 { font-weight: 600; }
+.modal-content ul { list-style: disc; }
+.modal-content li { margin-bottom: 6px; }
+
+/* Genetics hidden category indicator */
+.genetics-hidden .data-label::after {
+ content: '⚠';
+ margin-left: 8px;
+ font-size: 0.8rem;
+}
+
+/* Install page */
+.install-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 48px 24px 80px;
+}
+.install-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 24px;
+}
+.install-header h1 {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: 8px;
+}
+.install-header p {
+ font-size: 1.15rem;
+ font-weight: 300;
+ color: var(--text-muted);
+}
+.install-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 24px;
+}
+.login-prompt {
+ background: var(--accent-light);
+ border: 1px solid var(--accent);
+ border-radius: 8px;
+ padding: 16px 24px;
+ margin-bottom: 24px;
+ font-size: 1rem;
+ font-weight: 300;
+ color: var(--text);
+}
+.login-prompt a {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+/* Tabs */
+.ai-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border);
+ padding: 0 24px;
+}
+.ai-tab {
+ padding: 16px 24px;
+ cursor: pointer;
+ border: none;
+ background: none;
+ font-family: inherit;
+ font-size: 1rem;
+ color: var(--text-muted);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all 0.2s;
+}
+.ai-tab:hover { color: var(--text); }
+.ai-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 500;
+}
+.ai-content {
+ display: none;
+ padding: 32px;
+}
+.ai-content.active { display: block; }
+.ai-content > p:first-child {
+ font-size: 1rem;
+ font-weight: 300;
+ color: var(--text-muted);
+ margin-bottom: 24px;
+}
+
+/* Steps */
+.step {
+ margin-bottom: 24px;
+ padding: 24px;
+ background: var(--bg);
+ border-radius: 8px;
+}
+.step:last-child { margin-bottom: 0; }
+.step-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+.step-num {
+ width: 32px;
+ height: 32px;
+ background: var(--accent);
+ color: white;
+ border-radius: 50%;
+ text-align: center;
+ line-height: 32px;
+ font-weight: 600;
+ font-size: 0.9rem;
+ flex-shrink: 0;
+}
+.step-num.muted { background: var(--text-muted); }
+.step-num.warning { background: #F59E0B; }
+.step h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+}
+.step p {
+ font-size: 1rem;
+ font-weight: 300;
+ color: var(--text-muted);
+ line-height: 1.8;
+ margin: 0;
+}
+.step p + p { margin-top: 12px; }
+.step a { color: var(--accent); }
+.step ul {
+ margin: 12px 0 0 0;
+ padding-left: 20px;
+ color: var(--text-muted);
+ font-weight: 300;
+ line-height: 1.8;
+}
+
+/* Code wrapper with copy button */
+.code-wrapper {
+ position: relative;
+ margin-top: 16px;
+}
+.code-wrapper pre {
+ background: #1C1917;
+ color: #F5F5F4;
+ padding: 16px;
+ padding-right: 48px;
+ border-radius: 6px;
+ font-family: "SF Mono", Monaco, monospace;
+ font-size: 0.85rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+ line-height: 1.6;
+}
+.copy-icon {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 4px;
+ opacity: 0.6;
+ transition: opacity 0.2s, background 0.2s;
+}
+.copy-icon:hover { opacity: 1; background: rgba(255,255,255,0.1); }
+.copy-icon svg { width: 18px; height: 18px; stroke: #A8A29E; fill: none; }
+.copy-icon.copied svg { stroke: var(--success); }
+
+/* Quick start box */
+.quick-start {
+ background: var(--bg);
+ border-radius: 8px;
+ padding: 24px;
+ margin-bottom: 24px;
+}
+.quick-start h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0 0 16px 0;
+}
+.quick-start p {
+ font-size: 1rem;
+ font-weight: 300;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+/* Step note (smaller text) */
+.step-note {
+ margin-top: 12px;
+ font-size: 0.9rem;
+}
+
+/* Install page mobile */
+@media (max-width: 768px) {
+ .install-container { padding: 24px 16px 48px; }
+ .install-header { flex-direction: column; gap: 16px; }
+ .install-header h1 { font-size: 2rem; }
+ .ai-tabs { padding: 0 16px; overflow-x: auto; }
+ .ai-tab { padding: 12px 16px; font-size: 0.9rem; white-space: nowrap; }
+ .ai-content { padding: 24px 16px; }
+ .step { padding: 20px 16px; }
+}
+@media (max-width: 480px) {
+ .install-container { padding: 16px 12px 32px; }
+ .install-header h1 { font-size: 1.75rem; }
+ .install-header p { font-size: 1rem; }
+ .ai-tabs { padding: 0 12px; }
+ .ai-tab { padding: 10px 12px; font-size: 0.85rem; }
+ .ai-content { padding: 20px 12px; }
+ .step { padding: 16px 12px; }
+ .code-wrapper pre { font-size: 0.8rem; padding: 12px; padding-right: 40px; }
+}
diff --git a/static/style.css.backup b/static/style.css.backup
new file mode 100644
index 0000000..c22f81d
--- /dev/null
+++ b/static/style.css.backup
@@ -0,0 +1,1391 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+:root {
+ --bg: #F8F7F6;
+ --bg-card: #FFFFFF;
+ --border: #E5E2DE;
+ --border-hover: #C4BFB8;
+ --text: #1C1917;
+ --text-muted: #78716C;
+ --text-subtle: #A8A29E;
+ --accent: #B45309;
+ --accent-hover: #92400E;
+ --accent-light: #FEF3C7;
+ --danger: #DC2626;
+ --danger-light: #FEF2F2;
+ --success: #059669;
+ --success-light: #ECFDF5;
+}
+
+body {
+ font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-weight: 400;
+ line-height: 1.5;
+ font-size: 15px;
+}
+
+/* Navigation */
+.nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 24px;
+ max-width: 1200px;
+ margin: 0 auto;
+ border-bottom: 1px solid var(--border);
+}
+
+.logo {
+ font-family: "Sora", sans-serif;
+ font-size: 1.1rem;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: lowercase;
+ color: #B45309;
+ text-decoration: none;
+}
+
+.nav-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+/* User menu with hover dropdown */
+.nav-user-menu {
+ position: relative;
+}
+
+.nav-user-name {
+ font-size: 0.85rem;
+ color: var(--text);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+
+.nav-user-name:hover {
+ background: var(--border);
+}
+
+.nav-user-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ padding: 4px;
+ min-width: 100px;
+ z-index: 100;
+}
+
+.nav-user-menu:hover .nav-user-dropdown {
+ display: block;
+
+}
+
+.nav-user-dropdown a {
+ display: block;
+
+ padding: 8px 12px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.85rem;
+ border-radius: 4px;
+}
+
+.nav-user-dropdown a:hover {
+ background: var(--bg);
+ color: var(--accent);
+}
+
+/* Language menu */
+.lang-menu {
+ position: relative;
+}
+
+.lang-current {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+.lang-current:hover {
+ border-color: var(--border-hover);
+}
+
+.lang-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ padding: 4px;
+ min-width: 120px;
+ z-index: 100;
+ margin-top: 4px;
+}
+
+.lang-menu:hover .lang-dropdown {
+ display: block;
+
+}
+
+.lang-dropdown a {
+ display: block;
+
+ padding: 6px 12px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.85rem;
+ border-radius: 4px;
+}
+
+.lang-dropdown a:hover {
+ background: var(--bg);
+}
+
+.lang-dropdown a.active {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.nav-user {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.nav-user a {
+ color: var(--text);
+ text-decoration: none;
+}
+
+.nav-user a:hover {
+ color: var(--accent);
+}
+
+.lang-picker {
+ font-size: 1rem;
+ color: var(--text-muted);
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+/* Container */
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 40px 20px;
+}
+
+.container-narrow {
+ max-width: 360px;
+ margin: 0 auto;
+ padding: 60px 20px 40px;
+}
+
+/* Dossier header */
+.dossier-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 24px;
+}
+
+.dossier-header-left h1 {
+ margin-bottom: 0;
+}
+
+.dossier-header-left p {
+ margin: 4px 0 0 0;
+}
+
+/* Coming soon badge */
+.badge-soon {
+ font-size: 0.7rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+
+.data-card.coming-soon {
+ opacity: 0.6;
+}
+
+/* Typography */
+h1 {
+ font-size: 2.25rem;
+ font-weight: 300;
+ line-height: 1.2;
+ margin-bottom: 16px;
+ letter-spacing: -0.03em;
+ color: var(--text);
+}
+
+h1.small {
+ font-size: 1.5rem;
+ font-weight: 300;
+ margin-bottom: 4px;
+}
+
+h2 {
+ font-size: 1.5rem;
+ font-weight: 300;
+ margin-bottom: 12px;
+ letter-spacing: -0.02em;
+}
+
+h3 {
+ font-size: 1.125rem;
+ font-weight: 500;
+ margin-bottom: 4px;
+ color: var(--text);
+}
+
+.intro {
+ font-size: 1rem;
+ color: var(--text-muted);
+ margin-bottom: 32px;
+}
+
+.intro.small {
+ font-size: 1rem;
+ margin-bottom: 24px;
+}
+
+.section-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-subtle);
+ margin-bottom: 12px;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 1rem;
+ font-family: inherit;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-light);
+}
+
+.form-group input.code-input {
+ font-size: 1.375rem;
+ text-align: center;
+ letter-spacing: 0.4em;
+ font-weight: 500;
+ font-family: "SF Mono", "Monaco", monospace;
+}
+
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.radio-group {
+ display: flex;
+ gap: 16px;
+}
+
+.radio-group label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 1rem;
+ color: var(--text);
+ cursor: pointer;
+ font-weight: 400;
+}
+
+.radio-group input {
+ width: auto;
+ accent-color: var(--accent);
+}
+
+.checkbox-group label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 1rem;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.checkbox-group input {
+ width: auto;
+ accent-color: var(--accent);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 18px;
+ font-size: 1rem;
+ font-weight: 500;
+ font-family: inherit;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: all 0.15s;
+ border: none;
+ cursor: pointer;
+ text-align: center;
+ gap: 6px;
+}
+
+.btn-full {
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #FFFFFF;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-card);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ border-color: var(--border-hover);
+ background: var(--bg);
+}
+
+.btn-danger {
+ background: var(--danger-light);
+ color: var(--danger);
+ border: 1px solid #FECACA;
+}
+
+.btn-danger:hover {
+ background: #FEE2E2;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 1rem;
+}
+
+.btn-icon {
+ padding: 4px 8px;
+ background: transparent;
+ color: var(--text-subtle);
+ border: none;
+ font-size: 1rem;
+ line-height: 1;
+ border-radius: 4px;
+}
+
+.btn-icon:hover {
+ color: var(--danger);
+ background: var(--danger-light);
+}
+
+/* Cards */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.card-link {
+
+ text-decoration: none;
+ color: inherit;
+ padding: 0;
+ transition: all 0.15s;
+}
+
+.card-link:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+ text-decoration: none;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.card-header h3 {
+ margin-bottom: 0;
+}
+
+.card-meta {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+.card-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.card-add {
+ border: 2px dashed var(--border);
+ background: transparent;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.card-add:hover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.card-add .plus {
+ font-size: 1.75rem;
+ color: var(--accent);
+ margin-bottom: 6px;
+}
+
+.card-add span {
+ color: var(--text-muted);
+ font-size: 1rem;
+}
+
+/* Profiles grid */
+.profiles-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 12px;
+}
+
+/* Profile card hover */
+.profile-card {
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.profile-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+/* Profile badge */
+.badge {
+ display: inline-block;
+ padding: 2px 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ border-radius: 4px;
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.badge-care {
+ background: var(--success-light);
+ color: var(--success);
+}
+
+/* Access list */
+.access-list {
+ margin-top: 8px;
+}
+
+.access-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.access-item:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.access-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.access-name {
+ font-weight: 500;
+ font-size: 1rem;
+}
+
+.access-relation {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+/* Messages */
+.error {
+ background: var(--danger-light);
+ border: 1px solid #FECACA;
+ color: var(--danger);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+.info {
+ background: var(--accent-light);
+ border: 1px solid #FDE68A;
+ color: var(--accent);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+.success {
+ background: var(--success-light);
+ border: 1px solid #A7F3D0;
+ color: var(--success);
+ padding: 10px 14px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 1rem;
+}
+
+/* Trust section */
+.trust {
+ border-top: 1px solid var(--border);
+ padding-top: 32px;
+ margin-top: 32px;
+}
+
+.trust-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 20px;
+}
+
+.trust-item {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+.trust-item strong {
+
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 2px;
+}
+
+/* Footer */
+.footer {
+ margin-top: 40px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+/* Upload area */
+.upload-area {
+ border: 2px dashed var(--border);
+ border-radius: 8px;
+ padding: 40px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.15s;
+ background: var(--bg-card);
+}
+
+.upload-area:hover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.upload-area.dragover {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+
+.upload-icon {
+ color: var(--accent);
+ margin-bottom: 12px;
+}
+
+.upload-text {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+
+.upload-hint {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+/* Progress */
+.progress-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.progress-modal {
+ background: var(--bg-card);
+ padding: 32px;
+ border-radius: 12px;
+ text-align: center;
+ min-width: 280px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
+}
+
+.progress-bar-wrap {
+ background: var(--border);
+ border-radius: 4px;
+ height: 6px;
+ overflow: hidden;
+ margin-top: 16px;
+}
+
+.progress-bar {
+ background: var(--accent);
+ height: 100%;
+ width: 0%;
+ transition: width 0.2s;
+}
+
+.progress-detail {
+ margin-top: 12px;
+ font-size: 1rem;
+ color: var(--text-muted);
+ max-width: 320px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* File table */
+.file-table {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.file-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ font-size: 1rem;
+}
+
+.file-row:last-child {
+ border-bottom: none;
+}
+
+.file-row.file-deleted {
+ background: var(--bg);
+ color: var(--text-subtle);
+}
+
+.file-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.file-name {
+ color: var(--text);
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-deleted .file-name {
+ color: var(--text-subtle);
+ text-decoration: line-through;
+}
+
+.file-meta {
+ font-size: 1rem;
+ color: var(--text-subtle);
+}
+
+.file-status {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.status-expires {
+ color: var(--text-muted);
+}
+
+.status-deleted {
+ color: var(--text-subtle);
+ font-style: italic;
+}
+
+/* Link */
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Helpers */
+.text-center { text-align: center; }
+.text-muted { color: var(--text-muted); }
+.text-small { font-size: 1rem; }
+.mt-8 { margin-top: 8px; }
+.mt-16 { margin-top: 16px; }
+.mt-24 { margin-top: 24px; }
+.mb-8 { margin-bottom: 0; }
+.mb-16 { margin-bottom: 16px; }
+.mb-24 { margin-bottom: 24px; }
+
+/* Relation cards */
+.relation-cards {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.relation-card {
+
+ padding: 10px 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.15s;
+ font-size: 1rem;
+ background: var(--bg-card);
+}
+
+.relation-card:hover {
+ border-color: var(--accent);
+}
+
+.relation-card input {
+ display: none;
+}
+
+.relation-card input:checked + span {
+ font-weight: 600;
+}
+
+.relation-card:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.radio-pill {
+ display: inline-block;
+ padding: 6px 14px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ cursor: pointer;
+ margin-right: 4px;
+ transition: all 0.15s;
+ font-size: 1rem;
+ background: var(--bg-card);
+}
+
+.radio-pill:hover {
+ border-color: var(--accent);
+}
+
+.radio-pill input {
+ display: none;
+}
+
+.radio-pill:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+/* Data cards on profile page */
+.data-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 18px;
+ cursor: pointer;
+}
+
+.data-section-info h3 {
+ margin-bottom: 2px;
+}
+
+.data-section-meta {
+ font-size: 1rem;
+ color: var(--text-muted);
+}
+
+.data-section-arrow {
+ color: var(--accent);
+ font-size: 1.375rem;
+ font-weight: 500;
+}
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--text-muted);
+ font-size: 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+
+/* Category inline selector */
+.category-inline {
+ font-size: 0.8rem;
+ padding: 0.15rem 0.4rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text-muted);
+ cursor: pointer;
+}
+.category-inline:hover {
+ border-color: var(--accent);
+}
+.category-inline:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-light);
+}
+
+/* Status badge */
+.status-badge {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.4rem;
+ background: var(--accent-light);
+ color: var(--accent);
+ border-radius: 4px;
+ margin-right: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+/* Form select for category */
+.form-select {
+
+ width: 100%;
+ padding: 0.6rem 0.8rem;
+ font-size: 0.95rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+ color: var(--text);
+ cursor: pointer;
+}
+.form-select:hover {
+ border-color: var(--border-hover);
+}
+.form-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-light);
+}
+
+
+/* Data Cards - Andrew McCalip inspired */
+.data-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ overflow: hidden;
+}
+
+.data-card-header {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+ gap: 12px;
+}
+
+.data-card-indicator {
+ width: 4px;
+ height: 32px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.data-card-indicator.imaging { background: var(--accent); }
+.data-card-indicator.labs { background: #059669; }
+.data-card-indicator.uploads { background: #6366f1; }
+.data-card-indicator.vitals { background: #ec4899; }
+.data-card-indicator.medications { background: #8b5cf6; }
+.data-card-indicator.records { background: #06b6d4; }
+.data-card-indicator.journal { background: #f59e0b; }
+.data-card-indicator.privacy { background: #64748b; }
+
+.data-card-title {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.data-card-summary {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+/* Data Table */
+.data-table {
+ border-top: 1px solid var(--border);
+}
+
+.data-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px dashed var(--border);
+ gap: 16px;
+}
+
+.data-row:last-child {
+ border-bottom: none;
+}
+
+.data-row.expandable {
+ cursor: pointer;
+}
+
+.data-row.expandable:hover {
+ background: var(--bg);
+}
+
+.data-row-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.expand-icon {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-mono, monospace);
+ font-size: 14px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.data-row.expanded .expand-icon {
+ transform: rotate(45deg);
+}
+
+.data-row.single .data-row-main {
+ padding-left: 32px;
+}
+
+.data-label {
+ font-weight: 500;
+ color: var(--text);
+}
+
+.data-meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.data-values {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.data-value {
+ font-size: 0.85rem;
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.data-value.mono {
+ font-family: "SF Mono", "Monaco", "Consolas", monospace;
+ font-size: 0.8rem;
+}
+
+.data-date {
+ font-family: "SF Mono", "Monaco", "Consolas", monospace;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ min-width: 80px;
+ text-align: right;
+}
+
+/* Expandable children */
+.data-row-children {
+ display: none;
+ background: var(--bg);
+ border-top: 1px solid var(--border);
+}
+
+.data-row-children.show {
+ display: block;
+
+}
+
+.data-row.child {
+ padding-left: 48px;
+ border-bottom: 1px dashed var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+
+.data-row.child:last-child {
+ border-bottom: none;
+}
+
+.data-row.child .data-label {
+ font-weight: 400;
+ font-size: 0.9rem;
+ flex: 1;
+}
+
+/* Section heading - smaller, uppercase */
+.section-heading {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text);
+}
+
+/* Utility */
+.p-16 { padding: 16px; }
+
+/* Privacy actions row */
+.privacy-actions {
+ display: flex;
+ gap: 24px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.privacy-action {
+ font-size: 0.85rem;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.privacy-action:hover {
+ text-decoration: underline;
+}
+
+/* Share form */
+.share-form {
+ padding: 24px;
+}
+
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.form-row:last-child {
+ margin-bottom: 0;
+ margin-top: 24px;
+}
+
+.form-row > label {
+ flex: 0 0 180px;
+
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 0;
+}
+
+.form-input,
+.form-select {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 1rem;
+ font-family: inherit;
+ color: var(--text);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.form-input:focus,
+.form-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1);
+}
+
+.form-input::placeholder {
+ color: var(--text-subtle);
+}
+
+/* Custom select wrapper */
+.select-wrapper {
+ position: relative;
+}
+
+.select-wrapper::after {
+ content: '';
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid var(--text-muted);
+ pointer-events: none;
+}
+
+.form-select {
+ appearance: none;
+ -webkit-appearance: none;
+ cursor: pointer;
+ padding-right: 40px;
+}
+
+/* Checkbox styling */
+.checkbox-label {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ cursor: pointer;
+ font-weight: 400 !important;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ accent-color: var(--accent);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.checkbox-label span {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+/* Form row flex children */
+.form-row .form-input,
+.form-row .select-wrapper {
+ flex: 1;
+}
+
+.form-row .select-wrapper .form-select {
+ width: 100%;
+}
+
+/* Install page */
+.install-content {
+ padding: 16px 24px 24px;
+}
+
+.install-content p {
+ margin: 0 0 16px;
+ color: var(--text-muted);
+}
+
+.download-buttons {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.code-block {
+ background: #1e1e1e;
+ color: #d4d4d4;
+ padding: 16px;
+ border-radius: 8px;
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+ font-size: 0.85rem;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+.form-spacer {
+ flex: 0 0 180px;
+}
+
+/* Nav links */
+.nav-link {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ text-decoration: none;
+}
+
+.nav-link:hover {
+ color: var(--text);
+ text-decoration: none;
+}
+
+
+/* Privacy page */
+.privacy-container {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 60px 40px;
+}
+
+.privacy-container h1 {
+ font-family: Sora, sans-serif;
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: #2C1810;
+ margin-bottom: 16px;
+}
+
+.privacy-container .intro {
+ font-family: Sora, sans-serif;
+ font-size: 1.15rem;
+ font-weight: 300;
+ color: #4A3728;
+ line-height: 1.8;
+ margin-bottom: 48px;
+}
+
+.privacy-container h2 {
+ font-family: Sora, sans-serif;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: #2C1810;
+ margin-top: 48px;
+ margin-bottom: 24px;
+}
+
+.privacy-container h3 {
+ font-family: Sora, sans-serif;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #2C1810;
+ margin-top: 24px;
+ margin-bottom: 8px;
+}
+
+.privacy-container p {
+ font-family: Sora, sans-serif;
+ font-size: 1rem;
+ font-weight: 300;
+ color: #4A3728;
+ line-height: 1.8;
+ margin-bottom: 16px;
+}
+
+.privacy-container strong {
+ font-weight: 600;
+ color: #2C1810;
+}
+
+.privacy-container a {
+ color: #B45309;
+}
+
+.privacy-container .legal-section {
+ margin-top: 48px;
+ padding-top: 32px;
+ border-top: 1px solid #E5DDD3;
+}
+
+.inou-brand {
+ font-weight: 700;
+ color: #B45309;
+}
diff --git a/static/swagger.html b/static/swagger.html
new file mode 100644
index 0000000..ad4d984
--- /dev/null
+++ b/static/swagger.html
@@ -0,0 +1,26 @@
+
+
+
+
+ inou API
+
+
+
+
+
+
+
+
+
diff --git a/static/test_thumb.png b/static/test_thumb.png
new file mode 100644
index 0000000..c16c999
Binary files /dev/null and b/static/test_thumb.png differ
diff --git a/static/thumb_150px_q30.jpg b/static/thumb_150px_q30.jpg
new file mode 100644
index 0000000..f7ac9e1
Binary files /dev/null and b/static/thumb_150px_q30.jpg differ
diff --git a/static/thumb_150px_q50.jpg b/static/thumb_150px_q50.jpg
new file mode 100644
index 0000000..4fcca36
Binary files /dev/null and b/static/thumb_150px_q50.jpg differ
diff --git a/static/thumb_200px_q10.jpg b/static/thumb_200px_q10.jpg
new file mode 100644
index 0000000..eb78aa0
Binary files /dev/null and b/static/thumb_200px_q10.jpg differ
diff --git a/static/thumb_200px_q15.jpg b/static/thumb_200px_q15.jpg
new file mode 100644
index 0000000..ecb5d31
Binary files /dev/null and b/static/thumb_200px_q15.jpg differ
diff --git a/static/thumb_200px_q20.jpg b/static/thumb_200px_q20.jpg
new file mode 100644
index 0000000..3c3be25
Binary files /dev/null and b/static/thumb_200px_q20.jpg differ
diff --git a/static/thumb_256px_q10.jpg b/static/thumb_256px_q10.jpg
new file mode 100644
index 0000000..3086ae7
Binary files /dev/null and b/static/thumb_256px_q10.jpg differ
diff --git a/static/thumb_256px_q15.jpg b/static/thumb_256px_q15.jpg
new file mode 100644
index 0000000..d843954
Binary files /dev/null and b/static/thumb_256px_q15.jpg differ
diff --git a/static/thumb_300px_q10.jpg b/static/thumb_300px_q10.jpg
new file mode 100644
index 0000000..323e952
Binary files /dev/null and b/static/thumb_300px_q10.jpg differ
diff --git a/static/thumb_gif.gif b/static/thumb_gif.gif
new file mode 100644
index 0000000..13630c1
Binary files /dev/null and b/static/thumb_gif.gif differ
diff --git a/static/thumb_jpg_q10.jpg b/static/thumb_jpg_q10.jpg
new file mode 100644
index 0000000..8f368b0
Binary files /dev/null and b/static/thumb_jpg_q10.jpg differ
diff --git a/static/thumb_jpg_q20.jpg b/static/thumb_jpg_q20.jpg
new file mode 100644
index 0000000..06f7916
Binary files /dev/null and b/static/thumb_jpg_q20.jpg differ
diff --git a/static/thumb_jpg_q30.jpg b/static/thumb_jpg_q30.jpg
new file mode 100644
index 0000000..f7ac9e1
Binary files /dev/null and b/static/thumb_jpg_q30.jpg differ
diff --git a/static/thumb_jpg_q50.jpg b/static/thumb_jpg_q50.jpg
new file mode 100644
index 0000000..4fcca36
Binary files /dev/null and b/static/thumb_jpg_q50.jpg differ
diff --git a/static/thumb_jpg_q70.jpg b/static/thumb_jpg_q70.jpg
new file mode 100644
index 0000000..d62d68d
Binary files /dev/null and b/static/thumb_jpg_q70.jpg differ
diff --git a/static/thumb_jpg_q85.jpg b/static/thumb_jpg_q85.jpg
new file mode 100644
index 0000000..499be5e
Binary files /dev/null and b/static/thumb_jpg_q85.jpg differ
diff --git a/static/thumb_jpg_q95.jpg b/static/thumb_jpg_q95.jpg
new file mode 100644
index 0000000..3f2f862
Binary files /dev/null and b/static/thumb_jpg_q95.jpg differ
diff --git a/static/thumb_png_-1.png b/static/thumb_png_-1.png
new file mode 100644
index 0000000..fdf70db
Binary files /dev/null and b/static/thumb_png_-1.png differ
diff --git a/static/thumb_png_-2.png b/static/thumb_png_-2.png
new file mode 100644
index 0000000..601aea8
Binary files /dev/null and b/static/thumb_png_-2.png differ
diff --git a/static/thumb_png_-3.png b/static/thumb_png_-3.png
new file mode 100644
index 0000000..b2f8c71
Binary files /dev/null and b/static/thumb_png_-3.png differ
diff --git a/static/thumb_png_0.png b/static/thumb_png_0.png
new file mode 100644
index 0000000..1c605fd
Binary files /dev/null and b/static/thumb_png_0.png differ
diff --git a/static/thumbs.html b/static/thumbs.html
new file mode 100644
index 0000000..3eca0ee
--- /dev/null
+++ b/static/thumbs.html
@@ -0,0 +1,43 @@
+
+
+Thumbnail Test
+
+
+
+Thumbnails
+Loading...
+
+
+
diff --git a/static/viewer-screenshot.png b/static/viewer-screenshot.png
new file mode 100644
index 0000000..d7e3b1a
Binary files /dev/null and b/static/viewer-screenshot.png differ
diff --git a/static/viewer.css b/static/viewer.css
new file mode 100644
index 0000000..b949d55
--- /dev/null
+++ b/static/viewer.css
@@ -0,0 +1,513 @@
+@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap');
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { height: 100%; overflow: hidden; }
+body { background: #000; color: #fff; font-family: monospace; display: flex; flex-direction: column; }
+#header { padding: 10px 16px; background: #1a1a1a; display: flex; gap: 20px; align-items: center; font-family: 'Sora', sans-serif; flex-shrink: 0; }
+#header-left { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
+#header-right { display: flex; gap: 16px; align-items: center; margin-left: auto; }
+#coordsBox { display: flex; gap: 5px; align-items: center; }
+#coordsBox input { background: #222; color: #B45309; border: 1px solid #B45309; padding: 6px 10px; width: 200px; font-family: monospace; font-size: 12px; border-radius: 4px; }
+#coordsBox button { background: #B45309; color: #000; border: none; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; }
+#coordsBox button:hover { background: #D97706; }
+#branding { display: flex; align-items: center; gap: 6px; font-size: 15px; }
+#branding .brand-inou { color: #B45309; font-weight: 600; }
+#branding .brand-health { color: #888; font-weight: 300; }
+#panels { display: flex; flex: 1; overflow: hidden; min-height: 0; }
+.panel { flex: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid #333; }
+.panel:last-child { border-right: none; }
+.panel-header { padding: 5px 10px; background: #2a2a2a; font-size: 12px; }
+.panel-header .series-name { color: #B45309; font-weight: 500; }
+.panel-content { flex: 1; display: flex; justify-content: center; align-items: center; overflow: hidden; position: relative; min-height: 0; }
+.panel-content.zoomed { cursor: grab; }
+.panel-content.panning { cursor: grabbing; }
+.panel-content img { max-width: 100%; max-height: 100%; }
+.thumbnails { height: 110px; background: #111; display: flex; padding: 8px 16px; gap: 10px; align-items: stretch; font-family: 'Sora', sans-serif; }
+.wl-presets { display: flex; gap: 8px; flex-shrink: 0; }
+.wl-preset { display: flex; flex-direction: column; align-items: center; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; }
+.wl-preset:hover { opacity: 1; }
+.wl-preset.active { opacity: 1; }
+.wl-preset img { height: 70px; width: auto; border: 2px solid transparent; border-radius: 4px; }
+.wl-preset.active img { border-color: #B45309; }
+.wl-preset span { font-size: 10px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
+.wl-preset.active span { color: #B45309; }
+.slice-scrubber { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 0 20px; min-width: 200px; max-width: 500px; }
+.scrubber-label { font-size: 11px; color: #666; margin-bottom: 8px; }
+.scrubber-track { height: 8px; background: #222; border-radius: 4px; position: relative; cursor: pointer; }
+.scrubber-fill { height: 100%; background: linear-gradient(90deg, #F59E0B, #B45309); border-radius: 4px; position: absolute; left: 0; top: 0; pointer-events: none; }
+.scrubber-handle { width: 16px; height: 16px; background: #B45309; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%, -50%); cursor: grab; box-shadow: 0 0 8px rgba(245,158,11,0.5); }
+.scrubber-handle:active { cursor: grabbing; }
+.scrubber-ticks { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: #444; }
+.thumb { height: 70px; cursor: pointer; opacity: 0.6; }
+.thumb:hover { opacity: 0.8; }
+.thumb.active { opacity: 1; border: 2px solid #B45309; }
+select {
+ background: #1a1a1a;
+ color: #fff;
+ border: 1px solid #333;
+ padding: 8px 32px 8px 12px;
+ border-radius: 8px;
+ font-family: 'Sora', sans-serif;
+ font-size: 13px;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ transition: border-color 0.15s, background-color 0.15s;
+}
+select:hover { background-color: #222; border-color: #444; }
+select:focus { outline: none; border-color: #B45309; }
+button {
+ background: #1a1a1a;
+ color: #fff;
+ border: 1px solid #333;
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-family: 'Sora', sans-serif;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background-color 0.15s, border-color 0.15s;
+}
+button:hover { background: #222; border-color: #444; }
+button:disabled { background: #111; color: #555; cursor: not-allowed; border-color: #222; }
+button:disabled:hover { background: #111; }
+
+.sync-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: #888;
+ cursor: pointer;
+}
+.sync-label input[type="checkbox"] {
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border: 1px solid #444;
+ border-radius: 3px;
+ background: #1a1a1a;
+ cursor: pointer;
+ position: relative;
+}
+.sync-label input[type="checkbox"]:checked {
+ background: #B45309;
+ border-color: #B45309;
+}
+.sync-label input[type="checkbox"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 4px;
+ top: 1px;
+ width: 4px;
+ height: 8px;
+ border: solid #000;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+.sync-label span { white-space: nowrap; }
+
+#coordDisplay {
+ position: fixed;
+ bottom: 45px;
+ right: 20px;
+ background: rgba(0,0,0,0.8);
+ padding: 5px 12px;
+ font-size: 13px;
+ color: #B45309;
+ border: 1px solid rgba(245,158,11,0.3);
+ border-radius: 4px;
+ z-index: 1000;
+ font-variant-numeric: tabular-nums;
+}
+#wlHint {
+ position: fixed;
+ background: rgba(0,0,0,0.9);
+ padding: 6px 12px;
+ font-size: 12px;
+ font-family: 'Sora', sans-serif;
+ color: #fff;
+ border: 1px solid rgba(255,255,255,0.3);
+ border-radius: 6px;
+ z-index: 2000;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+#wlHint.show { opacity: 1; }
+#debugInfo {
+ color: #f88;
+ font-size: 11px;
+ display: none;
+}
+.rect-overlay {
+ position: absolute;
+ border: 2px solid #ff0;
+ background: rgba(255, 255, 0, 0.1);
+ pointer-events: none;
+}
+.img-wrapper {
+ position: relative;
+ display: inline-block;
+ transition: transform 0.15s ease-out;
+ transform-origin: center center;
+}
+.crosshair-h, .crosshair-v {
+ position: absolute;
+ background: rgba(245, 158, 11, 0.7);
+ pointer-events: none;
+}
+.crosshair-h {
+ height: 1px;
+ left: 0;
+ right: 0;
+}
+.crosshair-v {
+ width: 1px;
+ top: 0;
+ bottom: 0;
+}
+.panel-label {
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ color: #B45309;
+ font-size: 14px;
+ font-weight: bold;
+ text-shadow: 1px 1px 2px #000;
+}
+#helpBtn {
+ background: #222;
+ border: 2px solid #B45309;
+ color: #B45309;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+ margin-left: 20px;
+ line-height: 24px;
+ text-align: center;
+ padding: 0;
+}
+#helpBtn:hover { background: #B45309; color: #000; }
+
+/* Image info overlay - premium medical display */
+.image-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif;
+ z-index: 10;
+}
+
+/* Corner info blocks */
+.overlay-top-left {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+.overlay-top-right {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0;
+}
+.overlay-bottom-left {
+ position: absolute;
+ bottom: 16px;
+ left: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.overlay-bottom-right {
+ position: absolute;
+ bottom: 16px;
+ right: 16px;
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+}
+
+/* Patient and study info */
+.overlay-patient {
+ font-size: 15px;
+ font-weight: 500;
+ color: rgba(255,255,255,0.95);
+ text-shadow: 0 1px 4px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6);
+ letter-spacing: 0.3px;
+ line-height: 20px;
+}
+.overlay-datetime {
+ font-size: 13px;
+ color: rgba(255,255,255,0.95);
+ text-shadow: 0 1px 4px rgba(0,0,0,0.9);
+ line-height: 20px;
+}
+.overlay-accession {
+ font-size: 13px;
+ color: rgba(255,255,255,0.5);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ line-height: 20px;
+}
+.overlay-institution {
+ font-size: 13px;
+ color: rgba(255,255,255,0.5);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ line-height: 20px;
+}
+.overlay-study {
+ font-size: 11px;
+ font-weight: 400;
+ color: rgba(255,255,255,0.6);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ line-height: 20px;
+}
+
+/* Series name - prominent orange accent */
+.overlay-series {
+ font-size: 15px;
+ font-weight: 600;
+ color: #B45309;
+ text-shadow: 0 0 12px rgba(245,158,11,0.4), 0 1px 4px rgba(0,0,0,0.9);
+ letter-spacing: 0.5px;
+ line-height: 20px;
+}
+
+/* Slice info */
+.overlay-slice {
+ font-size: 20px;
+ font-weight: 300;
+ color: rgba(255,255,255,0.95);
+ text-shadow: 0 1px 4px rgba(0,0,0,0.9);
+ font-variant-numeric: tabular-nums;
+}
+.overlay-slice-total {
+ font-size: 13px;
+ color: rgba(255,255,255,0.5);
+}
+.overlay-pos {
+ font-size: 13px;
+ font-weight: 400;
+ color: rgba(255,255,255,0.9);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ font-variant-numeric: tabular-nums;
+ line-height: 20px;
+}
+.overlay-thickness {
+ font-size: 10px;
+ color: rgba(255,255,255,0.4);
+ line-height: 20px;
+}
+
+/* W/L display - elegant pill design */
+.overlay-wl {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ align-self: flex-end;
+ background: rgba(0,0,0,0.4);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ padding: 8px 14px;
+ border-radius: 20px;
+ border: 1px solid rgba(255,255,255,0.2);
+}
+.overlay-wl-item {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+.overlay-wl-label {
+ font-size: 10px;
+ font-weight: 500;
+ color: rgba(255,255,255,0.4);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+.overlay-wl-value {
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255,255,255,0.9);
+ font-variant-numeric: tabular-nums;
+ min-width: 55px;
+ text-align: right;
+}
+.overlay-wl-value.wl-adjusted {
+ color: #ffeb3b !important;
+ text-shadow: 0 0 8px rgba(255,235,59,0.5);
+}
+.overlay-wl-divider {
+ width: 1px;
+ height: 16px;
+ background: rgba(255,255,255,0.15);
+}
+
+/* Orientation markers - elegant edge labels */
+.overlay-orient {
+ position: absolute;
+ font-size: 20px;
+ font-weight: 600;
+ color: rgba(245,158,11,0.7);
+ text-shadow: 0 0 10px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6);
+ letter-spacing: 1px;
+}
+.overlay-orient-top {
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+.overlay-orient-bottom {
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+.overlay-orient-left {
+ left: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+.overlay-orient-right {
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+/* Zoom indicator */
+.overlay-zoom {
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba(255,255,255,0.6);
+ background: rgba(0,0,0,0.4);
+ padding: 4px 10px;
+ border-radius: 12px;
+ display: none;
+}
+.overlay-zoom.active {
+ display: inline-block;
+ color: #B45309;
+}
+/* Tour overlay */
+#tourOverlay {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 3000;
+}
+#tourOverlay.show { display: block; }
+#tourSpotlight {
+ position: absolute;
+ border: 3px solid #B45309;
+ border-radius: 8px;
+ box-shadow: 0 0 0 9999px rgba(0,0,0,0.5), 0 0 20px #B45309;
+ pointer-events: none;
+ transition: all 0.3s ease;
+ z-index: 1;
+}
+#tourTooltip {
+ position: absolute;
+ background: #111;
+ border: 2px solid #B45309;
+ color: #fff;
+ padding: 15px 20px;
+ max-width: 450px;
+ border-radius: 8px;
+ font-size: 14px;
+ line-height: 1.5;
+ z-index: 3001;
+ display: block;
+}
+#tourTooltip h3 { color: #B45309; margin: 0 0 8px 0; font-size: 16px; }
+#tourTooltip p { margin: 0 0 12px 0; }
+#tourTooltip .tour-nav { display: flex; justify-content: space-between; align-items: center; }
+#tourTooltip .tour-nav button {
+ background: #B45309; color: #000; border: none; padding: 6px 16px;
+ border-radius: 4px; cursor: pointer; font-weight: bold;
+}
+#tourTooltip .tour-nav button:hover { background: #0aa; }
+#tourTooltip .tour-skip { background: transparent !important; color: #888 !important; }
+#tourTooltip .tour-step { color: #666; font-size: 12px; }
+#tourBtn {
+ background: #222; border: 1px solid #555; color: #B45309;
+ padding: 5px 10px; cursor: pointer; font-size: 12px;
+}
+#tourBtn:hover { background: #333; }
+#helpModal {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.8);
+ z-index: 2000;
+ justify-content: center;
+ align-items: center;
+}
+#helpModal.show { display: flex; }
+#helpContent {
+ background: #111;
+ border: 1px solid #B45309;
+ padding: 20px 30px;
+ max-width: 400px;
+ font-size: 14px;
+ line-height: 1.8;
+}
+#helpContent h2 { color: #B45309; margin: 0 0 15px 0; font-size: 18px; }
+#helpContent table { width: 100%; }
+#helpContent td { padding: 4px 0; }
+#helpContent td:first-child { color: #ff0; width: 140px; }
+#helpContent hr { border: none; border-top: 1px solid #333; margin: 12px 0; }
+
+
+/* Light background overlay adjustments (for X-rays, etc.) */
+.panel.light-bg .overlay-patient,
+.panel.light-bg .overlay-accession,
+.panel.light-bg .overlay-study-desc,
+.panel.light-bg .overlay-datetime,
+.panel.light-bg .overlay-institution {
+ color: rgba(0,0,0,0.7);
+ text-shadow: 0 1px 2px rgba(255,255,255,0.8);
+}
+.panel.light-bg .overlay-series {
+ color: #92400E;
+ text-shadow: 0 1px 2px rgba(255,255,255,0.8);
+}
+.panel.light-bg .overlay-slice {
+ color: rgba(0,0,0,0.9);
+ text-shadow: 0 1px 2px rgba(255,255,255,0.8);
+}
+.panel.light-bg .overlay-slice-total {
+ color: rgba(0,0,0,0.5);
+}
+.panel.light-bg .overlay-pos,
+.panel.light-bg .overlay-thickness {
+ color: rgba(0,0,0,0.7);
+ text-shadow: 0 1px 2px rgba(255,255,255,0.8);
+}
+.panel.light-bg .overlay-orient {
+ color: rgba(0,0,0,0.7);
+ text-shadow: 0 1px 3px rgba(255,255,255,0.9);
+}
+.panel.light-bg .overlay-wl {
+ background: rgba(255,255,255,0.6);
+ border-color: rgba(0,0,0,0.2);
+}
+.panel.light-bg .overlay-wl-label {
+ color: rgba(0,0,0,0.5);
+}
+.panel.light-bg .overlay-wl-value {
+ color: rgba(0,0,0,0.9);
+}
diff --git a/static/viewer.js b/static/viewer.js
new file mode 100644
index 0000000..e0bd9eb
--- /dev/null
+++ b/static/viewer.js
@@ -0,0 +1,1648 @@
+let studies = [];
+let currentStudy = null;
+let studyInfo = {};
+let seriesList = [];
+let panels = [];
+let panelCount = 0;
+let is3DMode = false;
+let seriesListByOrientation = { SAG: [], AX: [], COR: [] };
+let tokenParam = ''; // Will be set from URL if present
+
+// W/L presets for common viewing windows
+const wlPresets = [
+ { name: 'Default', wc: null, ww: null },
+ { name: 'Brain', wc: 40, ww: 80 },
+ { name: 'Subdural', wc: 80, ww: 200 },
+ { name: 'Bone', wc: 500, ww: 2000 },
+ { name: 'Stroke', wc: 40, ww: 40 },
+ { name: 'Soft', wc: 50, ww: 400 }
+];
+
+// Detect if image background is light (for overlay color adjustment)
+function detectImageBrightness(img, panelIdx) {
+ const div = document.getElementById('panel-' + panelIdx);
+ if (!div || !img.complete || !img.naturalWidth) return;
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const sampleSize = 50;
+ canvas.width = sampleSize;
+ canvas.height = sampleSize;
+
+ // Sample top-left corner (where overlay text appears)
+ ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize);
+ const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
+
+ let total = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ total += (data[i] + data[i+1] + data[i+2]) / 3;
+ }
+ const avgBrightness = total / (data.length / 4);
+
+ // Toggle light-bg class based on brightness threshold
+ div.classList.toggle('light-bg', avgBrightness > 160);
+}
+
+function addToken(url) {
+ if (!tokenParam) return url;
+ return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam;
+}
+
+// Rectangle drawing state
+let isDrawing = false;
+let startX = 0, startY = 0;
+let currentRect = null;
+let activePanel = null;
+
+// Window/Level adjustment state
+let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw }
+let isAdjustingWL = false;
+let wlStartX = 0, wlStartY = 0;
+let wlStartWc = 0, wlStartWw = 0;
+let wlDebounceTimer = null;
+let wlPanel = -1;
+
+function getImageUrl(sliceId, seriesId) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) {
+ params.push("wc=" + Math.round(wlState[seriesId].wc));
+ params.push("ww=" + Math.round(wlState[seriesId].ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function getImageUrlWithWL(sliceId, seriesId, wc, ww) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (wc !== null && ww !== null) {
+ params.push("wc=" + Math.round(wc));
+ params.push("ww=" + Math.round(ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function initWLState(seriesId, slices) {
+ if (!wlState[seriesId] && slices.length > 0) {
+ const s = slices[0];
+ wlState[seriesId] = { adjusted: false,
+ wc: s.window_center || 128,
+ ww: s.window_width || 256,
+ originalWc: s.window_center || 128,
+ originalWw: s.window_width || 256
+ };
+ }
+}
+
+function resetWL(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (state) {
+ state.wc = state.originalWc;
+ state.ww = state.originalWw;
+ state.adjusted = false;
+ reloadPanelImages(panelIdx);
+ }
+}
+
+function reloadPanelImages(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ // Reload thumbnails too
+ const thumbs = div.querySelectorAll(".thumb");
+ thumbs.forEach((t, i) => {
+ t.src = getImageUrl(panel.slices[i].id, panel.seriesId);
+ });
+ updateOverlay(panelIdx);
+}
+
+function updateOverlay(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ if (!div) return;
+
+ // Get series info
+ const series = seriesList.find(s => s.id === panel.seriesId) ||
+ (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null);
+ const seriesName = series ? series.series_desc : "";
+
+ // Get slice info
+ const slice = panel.slices[panel.currentSlice];
+
+ // Get W/L info
+ let wc = "", ww = "";
+ let adjusted = false;
+ if (panel.seriesId && wlState[panel.seriesId]) {
+ const state = wlState[panel.seriesId];
+ if (state.adjusted) {
+ wc = Math.round(state.wc);
+ ww = Math.round(state.ww);
+ adjusted = true;
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+
+ // Get zoom level
+ const orientation = panel.orientation || "AX";
+ const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100;
+
+ // Update all overlay elements
+ const q = s => div.querySelector(s);
+
+ // Top left - patient/study info
+ if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " ");
+ if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : "";
+ if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || "";
+ if (q(".overlay-series")) q(".overlay-series").textContent = seriesName;
+ if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : "";
+ if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : "";
+
+ // Top right - technical info
+ if (q(".overlay-datetime")) {
+ let dt = "";
+ if (studyInfo.study_date) {
+ dt = studyInfo.study_date;
+ if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4);
+ }
+ q(".overlay-datetime").textContent = dt;
+ }
+ if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || "";
+ if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : "";
+ if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : "";
+
+ const wcEl = q(".overlay-wc");
+ const wwEl = q(".overlay-ww");
+ if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); }
+ if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); }
+
+ if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : "";
+
+ // Orientation markers based on image_orientation
+ updateOrientationMarkers(div, slice, orientation);
+}
+
+function updateOrientationMarkers(div, slice, orientationType) {
+ const left = div.querySelector(".overlay-orient-left");
+ const right = div.querySelector(".overlay-orient-right");
+ const top = div.querySelector(".overlay-orient-top");
+ const bottom = div.querySelector(".overlay-orient-bottom");
+
+ // Default markers based on orientation type
+ let markers = { left: "", right: "", top: "", bottom: "" };
+
+ if (orientationType === "AX") {
+ markers = { left: "R", right: "L", top: "A", bottom: "P" };
+ } else if (orientationType === "SAG") {
+ markers = { left: "A", right: "P", top: "S", bottom: "I" };
+ } else if (orientationType === "COR") {
+ markers = { left: "R", right: "L", top: "S", bottom: "I" };
+ }
+
+ // TODO: Parse image_orientation DICOM tag for exact orientation if needed
+
+ if (left) left.textContent = markers.left;
+ if (right) right.textContent = markers.right;
+ if (top) top.textContent = markers.top;
+ if (bottom) bottom.textContent = markers.bottom;
+}
+
+// Zoom state - shared by orientation type
+const zoomLevels = [1, 1.5, 2, 3, 4];
+let zoomState = {
+ AX: { level: 0, panX: 0, panY: 0 },
+ SAG: { level: 0, panX: 0, panY: 0 },
+ COR: { level: 0, panX: 0, panY: 0 }
+};
+let hoveredPanel = 0;
+let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper
+let scrollAccumulator = 0; // for slower slice scrolling
+
+function toggleHelp() {
+ document.getElementById('helpModal').classList.toggle('show');
+}
+
+// Tour functionality
+const tourSteps = [
+ {
+ target: () => document.getElementById('header'),
+ title: 'Welcome',
+ text: 'Explore medical imaging with AI assistance.
Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('button[onclick="setPanels(1)"]'),
+ title: 'Panel Layout',
+ text: 'Switch between 1, 2, or 3 panels to compare different series side by side.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('btn3d'),
+ title: '3D Crosshair Mode',
+ text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('helpBtn'),
+ title: 'Keyboard Shortcuts',
+ text: 'Click here for a quick reference of all keyboard and mouse controls.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('.panel-content img'),
+ title: 'Select a Region',
+ text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('rectInfo'),
+ title: 'AI Communication',
+ text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.',
+ pos: 'bottom'
+ }
+];
+
+let tourIndex = 0;
+
+function startTour() {
+ tourIndex = 0;
+ document.getElementById('tourOverlay').classList.add('show');
+ showTourStep();
+}
+
+function endTour() {
+ document.getElementById('tourOverlay').classList.remove('show');
+ localStorage.setItem('tourSeen', 'true');
+}
+
+function showTourStep() {
+ const step = tourSteps[tourIndex];
+ const target = step.target();
+ if (!target) { nextTourStep(); return; }
+
+ const rect = target.getBoundingClientRect();
+ const spotlight = document.getElementById('tourSpotlight');
+ const tooltip = document.getElementById('tourTooltip');
+
+ // Position spotlight
+ const pad = 8;
+ spotlight.style.left = (rect.left - pad) + 'px';
+ spotlight.style.top = (rect.top - pad) + 'px';
+ spotlight.style.width = (rect.width + pad * 2) + 'px';
+ spotlight.style.height = (rect.height + pad * 2) + 'px';
+
+ // Build tooltip
+ const isLastStep = tourIndex >= tourSteps.length - 1;
+ tooltip.innerHTML = '' + step.title + '
' + step.text + '
' +
+ '' +
+ (isLastStep ? '' : '') +
+ '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' +
+ '' +
+ '
';
+
+ // Position tooltip
+ const ttWidth = tourIndex === 0 ? 420 : 300;
+ const ttHeight = tourIndex === 0 ? 280 : 150;
+ let ttLeft = rect.left + rect.width / 2 - ttWidth / 2;
+ ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft));
+ tooltip.style.left = ttLeft + 'px';
+ tooltip.style.width = ttWidth + 'px';
+
+ // Welcome screen: center vertically
+ if (tourIndex === 0) {
+ tooltip.style.top = '50%';
+ tooltip.style.transform = 'translateY(-50%)';
+ tooltip.style.left = '50%';
+ tooltip.style.marginLeft = (-ttWidth / 2) + 'px';
+ return;
+ }
+ tooltip.style.transform = 'none';
+ tooltip.style.marginLeft = '0';
+
+ // Determine best vertical position
+ const spaceBelow = window.innerHeight - rect.bottom - 20;
+ const spaceAbove = rect.top - 20;
+ const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove;
+
+ tooltip.style.bottom = 'auto';
+ tooltip.style.top = 'auto';
+
+ if (placeBelow) {
+ let ttTop = rect.bottom + 15;
+ ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20);
+ tooltip.style.top = ttTop + 'px';
+ } else {
+ let ttTop = rect.top - ttHeight - 15;
+ ttTop = Math.max(10, ttTop);
+ tooltip.style.top = ttTop + 'px';
+ }
+}
+
+function nextTourStep() {
+ tourIndex++;
+ if (tourIndex >= tourSteps.length) {
+ endTour();
+ } else {
+ showTourStep();
+ }
+}
+
+// Pan state
+let isPanning = false;
+let panStartMouseX = 0, panStartMouseY = 0;
+let panStartPanX = 0, panStartPanY = 0;
+let panOrientation = null;
+
+function getCurrentImageRef(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return '';
+ const slice = panel.slices[panel.currentSlice];
+ const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown';
+ const study = studies.find(s => s.id == document.getElementById('studySelect').value);
+ const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown';
+ return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number;
+}
+
+async function init() {
+ // Create W/L hint element
+ const wlHint = document.createElement('div');
+ wlHint.id = 'wlHint';
+ wlHint.textContent = 'Image updates after 0.3s';
+ document.body.appendChild(wlHint);
+
+ // Extract token from URL for subsequent API calls
+ const params = new URLSearchParams(window.location.search);
+ tokenParam = params.get('token') || '';
+
+ const res = await fetch(addToken('/api/studies'));
+ studies = await res.json();
+ const sel = document.getElementById('studySelect');
+ studies.forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ opt.textContent = s.study_date + ' - ' + s.study_desc;
+ sel.appendChild(opt);
+ });
+
+ if (studies.length > 0) sel.selectedIndex = 0;
+
+ // Deep link by study/series GUID
+ const urlStudy = params.get('study');
+ const urlSeries = params.get('series');
+
+ if (urlStudy) {
+ const idx = studies.findIndex(s => s.id === urlStudy);
+ if (idx >= 0) sel.selectedIndex = idx;
+ }
+
+ if (studies.length > 0) {
+ await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise
+ if (urlSeries && seriesList.length > 0) {
+ const idx = seriesList.findIndex(s => s.id === urlSeries);
+ if (idx >= 0 && panels[0]) {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option
+ await loadSeries(0, seriesList[idx].id);
+ }
+ }
+ }
+
+ // Auto-start tour for first-time users
+ if (!localStorage.getItem('tourSeen')) {
+ setTimeout(startTour, 800);
+ }
+}
+
+async function addPanelEmpty() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+}
+
+async function loadStudy(numPanels = 2) {
+ const studyId = document.getElementById('studySelect').value;
+ const res = await fetch(addToken('/api/series?study=' + studyId));
+ seriesList = await res.json();
+ // Fetch study info for overlay
+ const infoRes = await fetch(addToken("/api/studies?study=" + studyId));
+ studyInfo = await infoRes.json();
+ is3DMode = false;
+
+ // Smart UI: show/hide elements based on series count
+ const seriesCount = seriesList.length;
+ const btn2panels = document.getElementById('btn2panels');
+ const btn3panels = document.getElementById('btn3panels');
+ const btn3d = document.getElementById('btn3d');
+ const syncLabel = document.getElementById('syncLabel');
+
+ if (seriesCount === 1) {
+ // Single series: hide multi-panel options, 3D, sync
+ btn2panels.style.display = 'none';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ numPanels = 1; // Force single panel
+ } else if (seriesCount === 2) {
+ // Two series: hide 3-panel, 3D, sync
+ btn2panels.style.display = '';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ if (numPanels > 2) numPanels = 2;
+ } else {
+ // 3+ series: show all, check 3D availability
+ btn2panels.style.display = '';
+ btn3panels.style.display = '';
+ btn3d.style.display = '';
+ syncLabel.style.display = '';
+
+ // Check if 3D mode is available (has SAG, AX, and COR)
+ const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG'));
+ const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX'));
+ const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR'));
+ btn3d.disabled = !(hasSag && hasAx && hasCor);
+ btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode';
+ }
+
+ await setPanels(numPanels);
+}
+
+async function set3DMode() {
+ const studyId = document.getElementById('studySelect').value;
+ if (!studyId) return;
+
+ is3DMode = true;
+ document.getElementById('syncScroll').checked = false;
+
+ // Fetch series for each orientation
+ const [sagRes, axRes, corRes] = await Promise.all([
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=COR'))
+ ]);
+ seriesListByOrientation.SAG = await sagRes.json() || [];
+ seriesListByOrientation.AX = await axRes.json() || [];
+ seriesListByOrientation.COR = await corRes.json() || [];
+
+ // Clear and create 3 panels
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+
+ await add3DPanel(0, 'SAG', seriesListByOrientation.SAG);
+ await add3DPanel(1, 'AX', seriesListByOrientation.AX);
+ await add3DPanel(2, 'COR', seriesListByOrientation.COR);
+}
+
+function pickBestSeries(seriesOptions) {
+ if (!seriesOptions || !seriesOptions.length) return null;
+ // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc)
+ let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc));
+ if (t1plus) return t1plus.id;
+ // Then T2
+ let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc));
+ if (t2) return t2.id;
+ // Then T1 (without contrast)
+ let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc));
+ if (t1) return t1.id;
+ // Fallback to first
+ return seriesOptions[0].id;
+}
+
+async function add3DPanel(idx, orientation, seriesOptions) {
+ panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+ div.innerHTML =
+ '' +
+ '' +
+ '
![]()
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+
+ // Auto-select best series (T1+ > T2 > T1 > first)
+ const bestSeriesId = pickBestSeries(seriesOptions);
+ if (bestSeriesId) {
+ div.querySelector('select').value = bestSeriesId;
+ await loadSeries(idx, bestSeriesId);
+ }
+}
+
+async function setPanels(count) {
+ is3DMode = false;
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+ for (let i = 0; i < count; i++) {
+ await addPanel();
+ }
+}
+
+function getImageCoords(e, img) {
+ const rect = img.getBoundingClientRect();
+ const scaleX = img.naturalWidth / rect.width;
+ const scaleY = img.naturalHeight / rect.height;
+ const x = Math.round((e.clientX - rect.left) * scaleX);
+ const y = Math.round((e.clientY - rect.top) * scaleY);
+ return { x, y, rect, scaleX, scaleY };
+}
+
+function getPanelOrientation(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return 'AX';
+ // In 3D mode, orientation is stored on panel
+ if (panel.orientation) return panel.orientation;
+ // Otherwise, derive from series description
+ const series = seriesList.find(s => s.id == panel.seriesId);
+ if (series) {
+ const desc = series.series_desc.toUpperCase();
+ if (desc.includes('SAG')) return 'SAG';
+ if (desc.includes('COR')) return 'COR';
+ }
+ return 'AX'; // default
+}
+
+function applyZoom(orientation) {
+ const state = zoomState[orientation];
+ const zoom = zoomLevels[state.level];
+ panels.forEach((p, idx) => {
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (!div) return;
+ const wrapper = div.querySelector('.img-wrapper');
+ const content = div.querySelector('.panel-content');
+ wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)';
+ content.classList.toggle('zoomed', state.level > 0);
+ }
+ });
+}
+
+function zoomIn(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level < zoomLevels.length - 1) {
+ state.level++;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function zoomOut(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level > 0) {
+ state.level--;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function resetZoom(orientation) {
+ const state = zoomState[orientation];
+ state.level = 0;
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+}
+
+async function addPanel() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const wrapper = div.querySelector('.img-wrapper');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ // Update coords in image space
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ // Mouse down - start drawing
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ // Mouse up - finish drawing
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+ // Auto-load default series, but skip if URL has a series parameter (will be loaded by init)
+ const params = new URLSearchParams(window.location.search);
+ const urlSeries = params.get('series');
+ if (idx < seriesList.length && !(idx === 0 && urlSeries)) {
+ // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts
+ const preferred = pickDefaultSeries(seriesList, idx);
+ if (preferred) {
+ const select = div.querySelector('select');
+ if (select) select.value = preferred.id;
+ await loadSeries(idx, preferred.id);
+ }
+ }
+}
+
+function pickDefaultSeries(series, panelIdx) {
+ // Score each series - lower is better
+ const scored = series.map(s => {
+ const desc = s.series_desc.toUpperCase();
+ let score = 100;
+
+ // Strongly prefer structural sequences
+ if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50;
+ if (desc.includes('T1')) score -= 40;
+ if (desc.includes('FLAIR')) score -= 35;
+
+ // Prefer axial for comparison
+ if (desc.includes('AX')) score -= 20;
+
+ // Avoid diffusion/DWI/DTI
+ if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100;
+ if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80;
+ if (desc.includes('ADC') || desc.includes('TRACE')) score += 60;
+
+ // Prefer moderate slice counts (20-50 is typical for structural)
+ if (s.slice_count > 200) score += 50;
+ if (s.slice_count > 500) score += 50;
+
+ return { ...s, score };
+ });
+
+ // Sort by score
+ scored.sort((a, b) => a.score - b.score);
+
+ // For panel 0, pick best. For panel 1+, pick next best with SAME orientation
+ if (panelIdx === 0) {
+ return scored[0];
+ } else {
+ // Get orientation of first panel's pick
+ const firstPick = scored[0];
+ const firstDesc = firstPick.series_desc.toUpperCase();
+ let firstOrientation = 'AX';
+ if (firstDesc.includes('SAG')) firstOrientation = 'SAG';
+ else if (firstDesc.includes('COR')) firstOrientation = 'COR';
+
+ // Find next best with same orientation (excluding first pick)
+ const sameOrientation = scored.filter(s => {
+ if (s.id === firstPick.id) return false;
+ const desc = s.series_desc.toUpperCase();
+ if (firstOrientation === 'SAG') return desc.includes('SAG');
+ if (firstOrientation === 'COR') return desc.includes('COR');
+ return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR'));
+ });
+
+
+ return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0];
+ }
+}
+
+function showRectFromInput() {
+ const input = document.getElementById('rectCoords').value;
+ const debug = document.getElementById('debugInfo');
+ debug.textContent = 'Parsing: ' + input;
+
+ // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)"
+ const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/);
+ if (fullMatch) {
+ const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch;
+ debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum;
+
+ // Find matching study
+ const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim()));
+ if (!study) {
+ debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim();
+ debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', ');
+ return;
+ }
+ debug.textContent = 'Found study id=' + study.id;
+
+ document.getElementById('studySelect').value = study.id;
+ fetch(addToken('/api/series?study=' + study.id))
+ .then(res => res.json())
+ .then(series => {
+ seriesList = series;
+ debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', ');
+ const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim());
+ if (!targetSeries) {
+ debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"';
+ return;
+ }
+ debug.textContent = 'Found series id=' + targetSeries.id;
+ setPanels(1).then(() => {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.value = targetSeries.id;
+ loadSeries(0, targetSeries.id).then(() => {
+ const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum));
+ debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum;
+ if (sliceIdx >= 0) {
+ goToSlice(0, sliceIdx);
+ setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100);
+ }
+ });
+ });
+ });
+ return;
+ }
+
+ debug.textContent = 'No full match, trying coords only...';
+ // Fallback: just coordinates
+ const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/);
+ if (!match) {
+ debug.textContent = 'No coord match either';
+ return;
+ }
+
+ const x1 = parseInt(match[1]), y1 = parseInt(match[2]);
+ const x2 = parseInt(match[3]), y2 = parseInt(match[4]);
+ debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2;
+
+ const panelIdx = activePanel !== null ? activePanel : 0;
+ drawRect(panelIdx, x1, y1, x2, y2);
+}
+
+function drawRect(panelIdx, x1, y1, x2, y2) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+
+ const img = panel.querySelector('.panel-content img');
+ const rectOverlay = panel.querySelector('.rect-overlay');
+
+ const rect = img.getBoundingClientRect();
+ // Divide out zoom since rect overlay is inside the transformed wrapper
+ const orientation = getPanelOrientation(panelIdx);
+ const zoom = zoomLevels[zoomState[orientation].level];
+ const scaleX = rect.width / img.naturalWidth / zoom;
+ const scaleY = rect.height / img.naturalHeight / zoom;
+
+ rectOverlay.style.left = (x1 * scaleX) + 'px';
+ rectOverlay.style.top = (y1 * scaleY) + 'px';
+ rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px';
+ rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px';
+ rectOverlay.style.display = 'block';
+
+ currentRect = { panelIdx, x1, y1, x2, y2 };
+ activePanel = panelIdx;
+}
+
+function copyCoords() {
+ const input = document.getElementById('rectCoords');
+ input.select();
+ document.execCommand('copy');
+}
+
+function clearRect() {
+ document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none');
+ document.getElementById('rectCoords').value = '';
+ currentRect = null;
+}
+
+async function loadSeries(panelIdx, seriesId) {
+ if (!seriesId) return;
+ const res = await fetch(addToken('/api/slices?series=' + seriesId));
+ const data = await res.json();
+ const slices = data.slices || data;
+ panels[panelIdx].seriesId = seriesId;
+ panels[panelIdx].slices = slices;
+ panels[panelIdx].currentSlice = 0;
+ initWLState(seriesId, slices);
+
+ const panel = document.getElementById('panel-' + panelIdx);
+ const thumbs = panel.querySelector('.thumbnails');
+
+ // Create W/L presets + scrubber
+ const midSliceId = slices[Math.floor(slices.length / 2)]?.id;
+ const presetsHtml = wlPresets.map((p, i) =>
+ '' +
+ '
 + ')
' +
+ '
' + p.name + ' '
+ ).join('');
+
+ const scrubberHtml =
+ '' +
+ '
Slice 1 / ' + slices.length + '
' +
+ '
' +
+ '
1' + slices.length + '
' +
+ '
';
+
+ thumbs.innerHTML = '' + presetsHtml + '
' + scrubberHtml;
+
+ // Setup scrubber interaction
+ setupScrubber(panelIdx);
+
+ // Preload all slice images for smooth scrolling
+ slices.forEach(s => {
+ const img = new Image();
+ img.src = getImageUrlWithWL(s.id, seriesId, null, null);
+ });
+
+ // Start at middle slice
+ const midSlice = Math.floor(slices.length / 2);
+ goToSlice(panelIdx, midSlice);
+}
+
+function update3DCrosshairs() {
+ if (!is3DMode) return;
+
+ const getData = (p) => {
+ if (!p || !p.slices.length) return null;
+ const s = p.slices[p.currentSlice];
+ // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz"
+ let rowVec = [1,0,0], colVec = [0,1,0];
+ if (s.image_orientation) {
+ const parts = s.image_orientation.split('\\').map(Number);
+ if (parts.length === 6) {
+ rowVec = [parts[0], parts[1], parts[2]];
+ colVec = [parts[3], parts[4], parts[5]];
+ }
+ }
+
+ // Compute CENTER of slice (not corner)
+ const psRow = s.pixel_spacing_row || 0.5;
+ const psCol = s.pixel_spacing_col || 0.5;
+ const halfWidth = (s.cols / 2) * psCol;
+ const halfHeight = (s.rows / 2) * psRow;
+
+ const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0];
+ const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1];
+ const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2];
+
+ return {
+ pos_x: s.pos_x,
+ pos_y: s.pos_y,
+ pos_z: s.pos_z,
+ center_x: centerX,
+ center_y: centerY,
+ center_z: centerZ,
+ rows: s.rows,
+ cols: s.cols,
+ psRow: psRow,
+ psCol: psCol,
+ rowVec: rowVec,
+ colVec: colVec
+ };
+ };
+
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
+
+ const sagPanel = panels.find(p => p.orientation === 'SAG');
+ const axPanel = panels.find(p => p.orientation === 'AX');
+ const corPanel = panels.find(p => p.orientation === 'COR');
+
+ const sagData = getData(sagPanel);
+ const axData = getData(axPanel);
+ const corData = getData(corPanel);
+
+ panels.forEach((p, idx) => {
+ if (!p.slices.length || !p.orientation) return;
+
+ const div = document.getElementById('panel-' + idx);
+ const img = div.querySelector('.panel-content img');
+ const hLine = div.querySelector('.crosshair-h');
+ const vLine = div.querySelector('.crosshair-v');
+
+ if (!img.naturalWidth) {
+ hLine.style.display = 'none';
+ vLine.style.display = 'none';
+ return;
+ }
+
+ const myData = getData(p);
+ const rect = img.getBoundingClientRect();
+ // Divide out zoom since crosshairs are inside the transformed wrapper
+ const zoom = zoomLevels[zoomState[p.orientation].level];
+ const scaleX = rect.width / img.naturalWidth / zoom;
+ const scaleY = rect.height / img.naturalHeight / zoom;
+
+ // Build target point from CENTER of other slices
+ // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y
+ let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z;
+ if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x;
+ if (axData && p.orientation !== 'AX') targetZ = axData.center_z;
+ if (corData && p.orientation !== 'COR') targetY = corData.center_y;
+
+ // Offset from corner to target
+ const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z];
+
+ // Project onto row/col directions
+ const vPixel = dot(offset, myData.rowVec) / myData.psCol;
+ const hPixel = dot(offset, myData.colVec) / myData.psRow;
+
+ if (hPixel >= 0 && hPixel <= myData.rows) {
+ hLine.style.top = (hPixel * scaleY) + 'px';
+ hLine.style.display = 'block';
+ } else {
+ hLine.style.display = 'none';
+ }
+
+ if (vPixel >= 0 && vPixel <= myData.cols) {
+ vLine.style.left = (vPixel * scaleX) + 'px';
+ vLine.style.display = 'block';
+ } else {
+ vLine.style.display = 'none';
+ }
+ });
+}
+
+function goToSlice(panelIdx, sliceIdx) {
+ const panel = panels[panelIdx];
+ if (!panel.slices.length) return;
+ panel.currentSlice = sliceIdx;
+
+ const div = document.getElementById('panel-' + panelIdx);
+ const img = div.querySelector('.panel-content img');
+ img.onload = () => detectImageBrightness(img, panelIdx);
+ img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId);
+
+ // Clear rectangle when changing slice
+ div.querySelector('.rect-overlay').style.display = 'none';
+
+ div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx));
+
+ // Update scrubber position
+ updateScrubber(panelIdx, sliceIdx);
+
+ updateOverlay(panelIdx);
+
+ // Update crosshairs in 3D mode
+ if (is3DMode) {
+ setTimeout(update3DCrosshairs, 50);
+ }
+
+ if (document.getElementById('syncScroll').checked && !is3DMode) {
+ const loc = panel.slices[sliceIdx].slice_location;
+ panels.forEach((p, i) => {
+ if (i !== panelIdx && p.slices.length) {
+ const closest = p.slices.reduce((prev, curr, idx) =>
+ Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0);
+ if (p.currentSlice !== closest) {
+ p.currentSlice = closest;
+ const pDiv = document.getElementById('panel-' + i);
+ pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId);
+ pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest));
+ updateOverlay(i);
+ }
+ }
+ });
+ }
+}
+
+// Track hovered panel for keyboard zoom
+document.addEventListener('mousemove', (e) => {
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ hoveredPanel = idx;
+ }
+ }
+ });
+}, { passive: true });
+
+document.addEventListener('wheel', e => {
+ if (!panels.length) return;
+
+ // Find which panel the mouse is over
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+
+ if (targetPanel < 0) return;
+ hoveredPanel = targetPanel;
+
+ // Track cursor relative to wrapper (for zoom-to-cursor)
+ // Account for current zoom since getBoundingClientRect returns transformed bounds
+ const div = document.getElementById('panel-' + targetPanel);
+ const wrapper = div.querySelector('.img-wrapper');
+ const wrapperRect = wrapper.getBoundingClientRect();
+ const orientation = getPanelOrientation(targetPanel);
+ const currentZoom = zoomLevels[zoomState[orientation].level];
+ cursorX = (e.clientX - wrapperRect.left) / currentZoom;
+ cursorY = (e.clientY - wrapperRect.top) / currentZoom;
+
+ // Shift+wheel = zoom
+ if (e.shiftKey) {
+ e.preventDefault();
+ const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) {
+ zoomIn(targetPanel);
+ } else if (delta > 0) {
+ zoomOut(targetPanel);
+ }
+ return;
+ }
+
+ // Regular wheel = scroll slices
+ const delta = e.deltaY > 0 ? 1 : -1;
+ const p = panels[targetPanel];
+ if (!p.slices.length) return;
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx);
+}, { passive: false });
+
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ if (document.getElementById('tourOverlay').classList.contains('show')) {
+ endTour();
+ } else if (document.getElementById('helpModal').classList.contains('show')) {
+ toggleHelp();
+ } else {
+ clearRect();
+ }
+ return;
+ }
+ if (!panels.length) return;
+
+ // +/- for zoom (affects hovered panel's orientation group)
+ if (e.key === '+' || e.key === '=') {
+ e.preventDefault();
+ zoomIn(hoveredPanel);
+ return;
+ }
+ if (e.key === '-' || e.key === '_') {
+ e.preventDefault();
+ zoomOut(hoveredPanel);
+ return;
+ }
+
+ // Arrow keys for slice navigation
+ let delta = 0;
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1;
+ if (delta === 0) return;
+ e.preventDefault();
+ const p = panels[0];
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(0, newIdx);
+});
+
+// Cancel drawing if mouse leaves window
+document.addEventListener('mouseup', (e) => {
+ isDrawing = false;
+ if (isPanning) {
+ isPanning = false;
+ // Restore transition
+ document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = '');
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning'));
+ }
+});
+
+// Shift+click pan
+document.addEventListener('mousedown', (e) => {
+ if (e.button !== 0 || !e.shiftKey) return;
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ const state = zoomState[orientation];
+ if (state.level === 0) return; // no pan at 1x zoom
+
+ isPanning = true;
+ panOrientation = orientation;
+ panStartMouseX = e.clientX;
+ panStartMouseY = e.clientY;
+ panStartPanX = state.panX;
+ panStartPanY = state.panY;
+ // Disable transition during pan for smooth movement
+ panels.forEach((p, idx) => {
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (div) div.querySelector('.img-wrapper').style.transition = 'none';
+ }
+ });
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning'));
+});
+
+document.addEventListener('mousemove', (e) => {
+ if (!isPanning || !panOrientation) return;
+
+ const state = zoomState[panOrientation];
+ const zoom = zoomLevels[state.level];
+ // With transform: scale(zoom) translate(panX, panY), translate values are scaled
+ // Divide by zoom for 1:1 screen-to-image movement
+ const dx = (e.clientX - panStartMouseX) / zoom;
+ const dy = (e.clientY - panStartMouseY) / zoom;
+ state.panX = panStartPanX + dx;
+ state.panY = panStartPanY + dy;
+ applyZoom(panOrientation);
+});
+
+// Double-click to reset zoom
+document.addEventListener('dblclick', (e) => {
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ resetZoom(orientation);
+});
+
+// Ctrl+click for Window/Level adjustment
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const panel = panels[targetPanel];
+ if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return;
+
+ isAdjustingWL = true;
+ isDrawing = false; // Prevent rect drawing
+ document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none");
+ wlPanel = targetPanel;
+ wlStartX = e.clientX;
+ wlStartY = e.clientY;
+ wlStartWc = wlState[panel.seriesId].wc;
+ wlStartWw = wlState[panel.seriesId].ww;
+ document.body.style.cursor = "crosshair";
+
+ // Show hint
+ const hint = document.getElementById('wlHint');
+ hint.style.left = (e.clientX + 15) + 'px';
+ hint.style.top = (e.clientY - 10) + 'px';
+ hint.classList.add('show');
+});
+
+document.addEventListener("mousemove", (e) => {
+ if (!isAdjustingWL || wlPanel < 0) return;
+
+ const panel = panels[wlPanel];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (!state) return;
+
+ // Horizontal = width, Vertical = center
+ const dx = e.clientX - wlStartX;
+ const dy = e.clientY - wlStartY;
+
+ state.ww = Math.max(1, wlStartWw + dx * 2);
+ state.wc = wlStartWc - dy * 2; // invert: drag up = brighter
+ state.adjusted = true;
+
+ // Update overlay C/W values in real-time
+ const div = document.getElementById("panel-" + wlPanel);
+ const wcEl = div.querySelector(".overlay-wc");
+ const wwEl = div.querySelector(".overlay-ww");
+ if (wcEl) wcEl.textContent = Math.round(state.wc);
+ if (wwEl) wwEl.textContent = Math.round(state.ww);
+
+ // Debounce image reload
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ wlDebounceTimer = setTimeout(() => {
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ }, 150);
+});
+
+document.addEventListener("mouseup", (e) => {
+ if (isAdjustingWL) {
+ isAdjustingWL = false;
+ document.body.style.cursor = "";
+ document.getElementById('wlHint').classList.remove('show');
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ if (wlPanel >= 0) {
+ reloadPanelImages(wlPanel);
+ }
+ wlPanel = -1;
+ }
+});
+
+// Track right-click for double-click detection
+let lastRightClickTime = 0;
+let lastRightClickPanel = -1;
+
+// Double right-click to reset Window/Level
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2) return;
+
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const now = Date.now();
+ if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) {
+ // Double right-click detected - reset W/L
+ resetWL(targetPanel);
+ lastRightClickTime = 0;
+ lastRightClickPanel = -1;
+ e.preventDefault();
+ return;
+ }
+ lastRightClickTime = now;
+ lastRightClickPanel = targetPanel;
+});
+
+// Update crosshairs on window resize
+// Prevent context menu on panels for right-click W/L adjustment
+document.addEventListener("contextmenu", (e) => {
+ if (!e.target.closest("#panels")) return;
+ e.preventDefault();
+});
+
+window.addEventListener('resize', () => {
+ if (is3DMode) update3DCrosshairs();
+});
+
+// W/L Preset functions
+function applyWLPreset(el) {
+ const panelIdx = parseInt(el.dataset.panel);
+ const wcAttr = el.dataset.wc;
+ const wwAttr = el.dataset.ww;
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+
+ // Update wlState - null means reset to original
+ if (wcAttr === 'null' || wwAttr === 'null') {
+ wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc;
+ wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw;
+ wlState[panel.seriesId].adjusted = false;
+ } else {
+ wlState[panel.seriesId].wc = parseInt(wcAttr);
+ wlState[panel.seriesId].ww = parseInt(wwAttr);
+ wlState[panel.seriesId].adjusted = true;
+ }
+
+ // Update active preset
+ const container = el.closest('.thumbnails');
+ container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active'));
+ el.classList.add('active');
+
+ // Reload image
+ reloadPanelImages(panelIdx);
+}
+
+function setupScrubber(panelIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const track = scrubber.querySelector('.scrubber-track');
+ let isDragging = false;
+
+ const updateFromPosition = (e) => {
+ const rect = track.getBoundingClientRect();
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
+ const pct = x / rect.width;
+ const sliceCount = panels[panelIdx].slices.length;
+ const sliceIdx = Math.round(pct * (sliceCount - 1));
+ goToSlice(panelIdx, sliceIdx);
+ };
+
+ track.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ updateFromPosition(e);
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (isDragging) updateFromPosition(e);
+ });
+
+ document.addEventListener('mouseup', () => {
+ isDragging = false;
+ });
+}
+
+function updateScrubber(panelIdx, sliceIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const sliceCount = panels[panelIdx].slices.length;
+ const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0;
+
+ const fill = scrubber.querySelector('.scrubber-fill');
+ const handle = scrubber.querySelector('.scrubber-handle');
+ const current = scrubber.querySelector('.scrubber-current');
+
+ if (fill) fill.style.width = pct + '%';
+ if (handle) handle.style.left = pct + '%';
+ if (current) current.textContent = sliceIdx + 1;
+}
+
+init();
diff --git a/static/viewer.js.bak b/static/viewer.js.bak
new file mode 100644
index 0000000..d0838b5
--- /dev/null
+++ b/static/viewer.js.bak
@@ -0,0 +1,1646 @@
+let studies = [];
+let currentStudy = null;
+let studyInfo = {};
+let seriesList = [];
+let panels = [];
+let panelCount = 0;
+let is3DMode = false;
+let seriesListByOrientation = { SAG: [], AX: [], COR: [] };
+let tokenParam = ''; // Will be set from URL if present
+
+// W/L presets for common viewing windows
+const wlPresets = [
+ { name: 'Default', wc: null, ww: null },
+ { name: 'Brain', wc: 40, ww: 80 },
+ { name: 'Subdural', wc: 80, ww: 200 },
+ { name: 'Bone', wc: 500, ww: 2000 },
+ { name: 'Stroke', wc: 40, ww: 40 },
+ { name: 'Soft', wc: 50, ww: 400 }
+];
+
+// Detect if image background is light (for overlay color adjustment)
+function detectImageBrightness(img, panelIdx) {
+ const div = document.getElementById('panel-' + panelIdx);
+ if (!div || !img.complete || !img.naturalWidth) return;
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const sampleSize = 50;
+ canvas.width = sampleSize;
+ canvas.height = sampleSize;
+
+ // Sample top-left corner (where overlay text appears)
+ ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize);
+ const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
+
+ let total = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ total += (data[i] + data[i+1] + data[i+2]) / 3;
+ }
+ const avgBrightness = total / (data.length / 4);
+
+ // Toggle light-bg class based on brightness threshold
+ div.classList.toggle('light-bg', avgBrightness > 160);
+}
+
+function addToken(url) {
+ if (!tokenParam) return url;
+ return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam;
+}
+
+// Rectangle drawing state
+let isDrawing = false;
+let startX = 0, startY = 0;
+let currentRect = null;
+let activePanel = null;
+
+// Window/Level adjustment state
+let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw }
+let isAdjustingWL = false;
+let wlStartX = 0, wlStartY = 0;
+let wlStartWc = 0, wlStartWw = 0;
+let wlDebounceTimer = null;
+let wlPanel = -1;
+
+function getImageUrl(sliceId, seriesId) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) {
+ params.push("wc=" + Math.round(wlState[seriesId].wc));
+ params.push("ww=" + Math.round(wlState[seriesId].ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function getImageUrlWithWL(sliceId, seriesId, wc, ww) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (wc !== null && ww !== null) {
+ params.push("wc=" + Math.round(wc));
+ params.push("ww=" + Math.round(ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function initWLState(seriesId, slices) {
+ if (!wlState[seriesId] && slices.length > 0) {
+ const s = slices[0];
+ wlState[seriesId] = { adjusted: false,
+ wc: s.window_center || 128,
+ ww: s.window_width || 256,
+ originalWc: s.window_center || 128,
+ originalWw: s.window_width || 256
+ };
+ }
+}
+
+function resetWL(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (state) {
+ state.wc = state.originalWc;
+ state.ww = state.originalWw;
+ state.adjusted = false;
+ reloadPanelImages(panelIdx);
+ }
+}
+
+function reloadPanelImages(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ // Reload thumbnails too
+ const thumbs = div.querySelectorAll(".thumb");
+ thumbs.forEach((t, i) => {
+ t.src = getImageUrl(panel.slices[i].id, panel.seriesId);
+ });
+ updateOverlay(panelIdx);
+}
+
+function updateOverlay(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ if (!div) return;
+
+ // Get series info
+ const series = seriesList.find(s => s.id === panel.seriesId) ||
+ (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null);
+ const seriesName = series ? series.series_desc : "";
+
+ // Get slice info
+ const slice = panel.slices[panel.currentSlice];
+
+ // Get W/L info
+ let wc = "", ww = "";
+ let adjusted = false;
+ if (panel.seriesId && wlState[panel.seriesId]) {
+ const state = wlState[panel.seriesId];
+ if (state.adjusted) {
+ wc = Math.round(state.wc);
+ ww = Math.round(state.ww);
+ adjusted = true;
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+
+ // Get zoom level
+ const orientation = panel.orientation || "AX";
+ const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100;
+
+ // Update all overlay elements
+ const q = s => div.querySelector(s);
+
+ // Top left - patient/study info
+ if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " ");
+ if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : "";
+ if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || "";
+ if (q(".overlay-series")) q(".overlay-series").textContent = seriesName;
+ if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : "";
+ if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : "";
+
+ // Top right - technical info
+ if (q(".overlay-datetime")) {
+ let dt = "";
+ if (studyInfo.study_date) {
+ dt = studyInfo.study_date;
+ if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4);
+ }
+ q(".overlay-datetime").textContent = dt;
+ }
+ if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || "";
+ if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : "";
+ if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : "";
+
+ const wcEl = q(".overlay-wc");
+ const wwEl = q(".overlay-ww");
+ if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); }
+ if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); }
+
+ if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : "";
+
+ // Orientation markers based on image_orientation
+ updateOrientationMarkers(div, slice, orientation);
+}
+
+function updateOrientationMarkers(div, slice, orientationType) {
+ const left = div.querySelector(".overlay-orient-left");
+ const right = div.querySelector(".overlay-orient-right");
+ const top = div.querySelector(".overlay-orient-top");
+ const bottom = div.querySelector(".overlay-orient-bottom");
+
+ // Default markers based on orientation type
+ let markers = { left: "", right: "", top: "", bottom: "" };
+
+ if (orientationType === "AX") {
+ markers = { left: "R", right: "L", top: "A", bottom: "P" };
+ } else if (orientationType === "SAG") {
+ markers = { left: "A", right: "P", top: "S", bottom: "I" };
+ } else if (orientationType === "COR") {
+ markers = { left: "R", right: "L", top: "S", bottom: "I" };
+ }
+
+ // TODO: Parse image_orientation DICOM tag for exact orientation if needed
+
+ if (left) left.textContent = markers.left;
+ if (right) right.textContent = markers.right;
+ if (top) top.textContent = markers.top;
+ if (bottom) bottom.textContent = markers.bottom;
+}
+
+// Zoom state - shared by orientation type
+const zoomLevels = [1, 1.5, 2, 3, 4];
+let zoomState = {
+ AX: { level: 0, panX: 0, panY: 0 },
+ SAG: { level: 0, panX: 0, panY: 0 },
+ COR: { level: 0, panX: 0, panY: 0 }
+};
+let hoveredPanel = 0;
+let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper
+let scrollAccumulator = 0; // for slower slice scrolling
+
+function toggleHelp() {
+ document.getElementById('helpModal').classList.toggle('show');
+}
+
+// Tour functionality
+const tourSteps = [
+ {
+ target: () => document.getElementById('header'),
+ title: 'Welcome to Inou',
+ text: '異能
"extraordinary ability"
Explore medical imaging with AI assistance.
Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('button[onclick="setPanels(1)"]'),
+ title: 'Panel Layout',
+ text: 'Switch between 1, 2, or 3 panels to compare different series side by side.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('btn3d'),
+ title: '3D Crosshair Mode',
+ text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('helpBtn'),
+ title: 'Keyboard Shortcuts',
+ text: 'Click here for a quick reference of all keyboard and mouse controls.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('.panel-content img'),
+ title: 'Select a Region',
+ text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('rectInfo'),
+ title: 'AI Communication',
+ text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.',
+ pos: 'bottom'
+ }
+];
+
+let tourIndex = 0;
+
+function startTour() {
+ tourIndex = 0;
+ document.getElementById('tourOverlay').classList.add('show');
+ showTourStep();
+}
+
+function endTour() {
+ document.getElementById('tourOverlay').classList.remove('show');
+ localStorage.setItem('tourSeen', 'true');
+}
+
+function showTourStep() {
+ const step = tourSteps[tourIndex];
+ const target = step.target();
+ if (!target) { nextTourStep(); return; }
+
+ const rect = target.getBoundingClientRect();
+ const spotlight = document.getElementById('tourSpotlight');
+ const tooltip = document.getElementById('tourTooltip');
+
+ // Position spotlight
+ const pad = 8;
+ spotlight.style.left = (rect.left - pad) + 'px';
+ spotlight.style.top = (rect.top - pad) + 'px';
+ spotlight.style.width = (rect.width + pad * 2) + 'px';
+ spotlight.style.height = (rect.height + pad * 2) + 'px';
+
+ // Build tooltip
+ const isLastStep = tourIndex >= tourSteps.length - 1;
+ tooltip.innerHTML = '' + step.title + '
' + step.text + '
' +
+ '' +
+ (isLastStep ? '' : '') +
+ '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' +
+ '' +
+ '
';
+
+ // Position tooltip
+ const ttWidth = tourIndex === 0 ? 420 : 300;
+ const ttHeight = tourIndex === 0 ? 280 : 150;
+ let ttLeft = rect.left + rect.width / 2 - ttWidth / 2;
+ ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft));
+ tooltip.style.left = ttLeft + 'px';
+ tooltip.style.width = ttWidth + 'px';
+
+ // Welcome screen: center vertically
+ if (tourIndex === 0) {
+ tooltip.style.top = '50%';
+ tooltip.style.transform = 'translateY(-50%)';
+ tooltip.style.left = '50%';
+ tooltip.style.marginLeft = (-ttWidth / 2) + 'px';
+ return;
+ }
+ tooltip.style.transform = 'none';
+ tooltip.style.marginLeft = '0';
+
+ // Determine best vertical position
+ const spaceBelow = window.innerHeight - rect.bottom - 20;
+ const spaceAbove = rect.top - 20;
+ const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove;
+
+ tooltip.style.bottom = 'auto';
+ tooltip.style.top = 'auto';
+
+ if (placeBelow) {
+ let ttTop = rect.bottom + 15;
+ ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20);
+ tooltip.style.top = ttTop + 'px';
+ } else {
+ let ttTop = rect.top - ttHeight - 15;
+ ttTop = Math.max(10, ttTop);
+ tooltip.style.top = ttTop + 'px';
+ }
+}
+
+function nextTourStep() {
+ tourIndex++;
+ if (tourIndex >= tourSteps.length) {
+ endTour();
+ } else {
+ showTourStep();
+ }
+}
+
+// Pan state
+let isPanning = false;
+let panStartMouseX = 0, panStartMouseY = 0;
+let panStartPanX = 0, panStartPanY = 0;
+let panOrientation = null;
+
+function getCurrentImageRef(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return '';
+ const slice = panel.slices[panel.currentSlice];
+ const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown';
+ const study = studies.find(s => s.id == document.getElementById('studySelect').value);
+ const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown';
+ return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number;
+}
+
+async function init() {
+ // Create W/L hint element
+ const wlHint = document.createElement('div');
+ wlHint.id = 'wlHint';
+ wlHint.textContent = 'Image updates after 0.3s';
+ document.body.appendChild(wlHint);
+
+ // Extract token from URL for subsequent API calls
+ const params = new URLSearchParams(window.location.search);
+ tokenParam = params.get('token') || '';
+
+ const res = await fetch(addToken('/api/studies'));
+ studies = await res.json();
+ const sel = document.getElementById('studySelect');
+ studies.forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ opt.textContent = s.study_date + ' - ' + s.study_desc;
+ sel.appendChild(opt);
+ });
+
+ if (studies.length > 0) sel.selectedIndex = 0;
+
+ // Deep link by study/series GUID
+ const urlStudy = params.get('study');
+ const urlSeries = params.get('series');
+
+ if (urlStudy) {
+ const idx = studies.findIndex(s => s.id === urlStudy);
+ if (idx >= 0) sel.selectedIndex = idx;
+ }
+
+ if (studies.length > 0) {
+ await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise
+ if (urlSeries && seriesList.length > 0) {
+ const idx = seriesList.findIndex(s => s.id === urlSeries);
+ if (idx >= 0 && panels[0]) {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option
+ await loadSeries(0, seriesList[idx].id);
+ }
+ }
+ }
+
+ // Auto-start tour for first-time users
+ if (!localStorage.getItem('tourSeen')) {
+ setTimeout(startTour, 800);
+ }
+}
+
+async function addPanelEmpty() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+}
+
+async function loadStudy(numPanels = 2) {
+ const studyId = document.getElementById('studySelect').value;
+ const res = await fetch(addToken('/api/series?study=' + studyId));
+ seriesList = await res.json();
+ // Fetch study info for overlay
+ const infoRes = await fetch(addToken("/api/studies?study=" + studyId));
+ studyInfo = await infoRes.json();
+ is3DMode = false;
+
+ // Smart UI: show/hide elements based on series count
+ const seriesCount = seriesList.length;
+ const btn2panels = document.getElementById('btn2panels');
+ const btn3panels = document.getElementById('btn3panels');
+ const btn3d = document.getElementById('btn3d');
+ const syncLabel = document.getElementById('syncLabel');
+
+ if (seriesCount === 1) {
+ // Single series: hide multi-panel options, 3D, sync
+ btn2panels.style.display = 'none';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ numPanels = 1; // Force single panel
+ } else if (seriesCount === 2) {
+ // Two series: hide 3-panel, 3D, sync
+ btn2panels.style.display = '';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ if (numPanels > 2) numPanels = 2;
+ } else {
+ // 3+ series: show all, check 3D availability
+ btn2panels.style.display = '';
+ btn3panels.style.display = '';
+ btn3d.style.display = '';
+ syncLabel.style.display = '';
+
+ // Check if 3D mode is available (has SAG, AX, and COR)
+ const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG'));
+ const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX'));
+ const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR'));
+ btn3d.disabled = !(hasSag && hasAx && hasCor);
+ btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode';
+ }
+
+ setPanels(numPanels);
+}
+
+async function set3DMode() {
+ const studyId = document.getElementById('studySelect').value;
+ if (!studyId) return;
+
+ is3DMode = true;
+ document.getElementById('syncScroll').checked = false;
+
+ // Fetch series for each orientation
+ const [sagRes, axRes, corRes] = await Promise.all([
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=COR'))
+ ]);
+ seriesListByOrientation.SAG = await sagRes.json() || [];
+ seriesListByOrientation.AX = await axRes.json() || [];
+ seriesListByOrientation.COR = await corRes.json() || [];
+
+ // Clear and create 3 panels
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+
+ await add3DPanel(0, 'SAG', seriesListByOrientation.SAG);
+ await add3DPanel(1, 'AX', seriesListByOrientation.AX);
+ await add3DPanel(2, 'COR', seriesListByOrientation.COR);
+}
+
+function pickBestSeries(seriesOptions) {
+ if (!seriesOptions || !seriesOptions.length) return null;
+ // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc)
+ let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc));
+ if (t1plus) return t1plus.id;
+ // Then T2
+ let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc));
+ if (t2) return t2.id;
+ // Then T1 (without contrast)
+ let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc));
+ if (t1) return t1.id;
+ // Fallback to first
+ return seriesOptions[0].id;
+}
+
+async function add3DPanel(idx, orientation, seriesOptions) {
+ panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+ div.innerHTML =
+ '' +
+ '' +
+ '
![]()
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+
+ // Auto-select best series (T1+ > T2 > T1 > first)
+ const bestSeriesId = pickBestSeries(seriesOptions);
+ if (bestSeriesId) {
+ div.querySelector('select').value = bestSeriesId;
+ await loadSeries(idx, bestSeriesId);
+ }
+}
+
+function setPanels(count) {
+ is3DMode = false;
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+ for (let i = 0; i < count; i++) {
+ addPanel();
+ }
+}
+
+function getImageCoords(e, img) {
+ const rect = img.getBoundingClientRect();
+ const scaleX = img.naturalWidth / rect.width;
+ const scaleY = img.naturalHeight / rect.height;
+ const x = Math.round((e.clientX - rect.left) * scaleX);
+ const y = Math.round((e.clientY - rect.top) * scaleY);
+ return { x, y, rect, scaleX, scaleY };
+}
+
+function getPanelOrientation(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return 'AX';
+ // In 3D mode, orientation is stored on panel
+ if (panel.orientation) return panel.orientation;
+ // Otherwise, derive from series description
+ const series = seriesList.find(s => s.id == panel.seriesId);
+ if (series) {
+ const desc = series.series_desc.toUpperCase();
+ if (desc.includes('SAG')) return 'SAG';
+ if (desc.includes('COR')) return 'COR';
+ }
+ return 'AX'; // default
+}
+
+function applyZoom(orientation) {
+ const state = zoomState[orientation];
+ const zoom = zoomLevels[state.level];
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (!div) return;
+ const wrapper = div.querySelector('.img-wrapper');
+ const content = div.querySelector('.panel-content');
+ wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)';
+ content.classList.toggle('zoomed', state.level > 0);
+ }
+ });
+}
+
+function zoomIn(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level < zoomLevels.length - 1) {
+ state.level++;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function zoomOut(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level > 0) {
+ state.level--;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function resetZoom(orientation) {
+ const state = zoomState[orientation];
+ state.level = 0;
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+}
+
+async function addPanel() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const wrapper = div.querySelector('.img-wrapper');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ // Update coords in image space
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ // Mouse down - start drawing
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ // Mouse up - finish drawing
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+ if (idx < seriesList.length) {
+ // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts
+ const preferred = pickDefaultSeries(seriesList, idx);
+ if (preferred) {
+ const select = div.querySelector('select');
+ if (select) select.value = preferred.id;
+ loadSeries(idx, preferred.id);
+ }
+ }
+}
+
+function pickDefaultSeries(series, panelIdx) {
+ // Score each series - lower is better
+ const scored = series.map(s => {
+ const desc = s.series_desc.toUpperCase();
+ let score = 100;
+
+ // Strongly prefer structural sequences
+ if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50;
+ if (desc.includes('T1')) score -= 40;
+ if (desc.includes('FLAIR')) score -= 35;
+
+ // Prefer axial for comparison
+ if (desc.includes('AX')) score -= 20;
+
+ // Avoid diffusion/DWI/DTI
+ if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100;
+ if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80;
+ if (desc.includes('ADC') || desc.includes('TRACE')) score += 60;
+
+ // Prefer moderate slice counts (20-50 is typical for structural)
+ if (s.slice_count > 200) score += 50;
+ if (s.slice_count > 500) score += 50;
+
+ return { ...s, score };
+ });
+
+ // Sort by score
+ scored.sort((a, b) => a.score - b.score);
+
+ // For panel 0, pick best. For panel 1+, pick next best with SAME orientation
+ if (panelIdx === 0) {
+ return scored[0];
+ } else {
+ // Get orientation of first panel's pick
+ const firstPick = scored[0];
+ const firstDesc = firstPick.series_desc.toUpperCase();
+ let firstOrientation = 'AX';
+ if (firstDesc.includes('SAG')) firstOrientation = 'SAG';
+ else if (firstDesc.includes('COR')) firstOrientation = 'COR';
+
+ // Find next best with same orientation (excluding first pick)
+ const sameOrientation = scored.filter(s => {
+ if (s.id === firstPick.id) return false;
+ const desc = s.series_desc.toUpperCase();
+ if (firstOrientation === 'SAG') return desc.includes('SAG');
+ if (firstOrientation === 'COR') return desc.includes('COR');
+ return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR'));
+ });
+
+
+ return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0];
+ }
+}
+
+function showRectFromInput() {
+ const input = document.getElementById('rectCoords').value;
+ const debug = document.getElementById('debugInfo');
+ debug.textContent = 'Parsing: ' + input;
+
+ // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)"
+ const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/);
+ if (fullMatch) {
+ const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch;
+ debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum;
+
+ // Find matching study
+ const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim()));
+ if (!study) {
+ debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim();
+ debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', ');
+ return;
+ }
+ debug.textContent = 'Found study id=' + study.id;
+
+ document.getElementById('studySelect').value = study.id;
+ fetch(addToken('/api/series?study=' + study.id))
+ .then(res => res.json())
+ .then(series => {
+ seriesList = series;
+ debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', ');
+ const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim());
+ if (!targetSeries) {
+ debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"';
+ return;
+ }
+ debug.textContent = 'Found series id=' + targetSeries.id;
+ setPanels(1);
+ setTimeout(() => {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.value = targetSeries.id;
+ loadSeries(0, targetSeries.id).then(() => {
+ const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum));
+ debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum;
+ if (sliceIdx >= 0) {
+ goToSlice(0, sliceIdx);
+ setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100);
+ }
+ });
+ }, 50);
+ });
+ return;
+ }
+
+ debug.textContent = 'No full match, trying coords only...';
+ // Fallback: just coordinates
+ const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/);
+ if (!match) {
+ debug.textContent = 'No coord match either';
+ return;
+ }
+
+ const x1 = parseInt(match[1]), y1 = parseInt(match[2]);
+ const x2 = parseInt(match[3]), y2 = parseInt(match[4]);
+ debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2;
+
+ const panelIdx = activePanel !== null ? activePanel : 0;
+ drawRect(panelIdx, x1, y1, x2, y2);
+}
+
+function drawRect(panelIdx, x1, y1, x2, y2) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+
+ const img = panel.querySelector('.panel-content img');
+ const rectOverlay = panel.querySelector('.rect-overlay');
+
+ const rect = img.getBoundingClientRect();
+ // Divide out zoom since rect overlay is inside the transformed wrapper
+ const orientation = getPanelOrientation(panelIdx);
+ const zoom = zoomLevels[zoomState[orientation].level];
+ const scaleX = rect.width / img.naturalWidth / zoom;
+ const scaleY = rect.height / img.naturalHeight / zoom;
+
+ rectOverlay.style.left = (x1 * scaleX) + 'px';
+ rectOverlay.style.top = (y1 * scaleY) + 'px';
+ rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px';
+ rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px';
+ rectOverlay.style.display = 'block';
+
+ currentRect = { panelIdx, x1, y1, x2, y2 };
+ activePanel = panelIdx;
+}
+
+function copyCoords() {
+ const input = document.getElementById('rectCoords');
+ input.select();
+ document.execCommand('copy');
+}
+
+function clearRect() {
+ document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none');
+ document.getElementById('rectCoords').value = '';
+ currentRect = null;
+}
+
+async function loadSeries(panelIdx, seriesId) {
+ if (!seriesId) return;
+ const res = await fetch(addToken('/api/slices?series=' + seriesId));
+ const slices = await res.json();
+ panels[panelIdx].seriesId = seriesId;
+ panels[panelIdx].slices = slices;
+ panels[panelIdx].currentSlice = 0;
+ initWLState(seriesId, slices);
+
+ const panel = document.getElementById('panel-' + panelIdx);
+ const thumbs = panel.querySelector('.thumbnails');
+
+ // Create W/L presets + scrubber
+ const midSliceId = slices[Math.floor(slices.length / 2)]?.id;
+ const presetsHtml = wlPresets.map((p, i) =>
+ '' +
+ '
 + ')
' +
+ '
' + p.name + ' '
+ ).join('');
+
+ const scrubberHtml =
+ '' +
+ '
Slice 1 / ' + slices.length + '
' +
+ '
' +
+ '
1' + slices.length + '
' +
+ '
';
+
+ thumbs.innerHTML = '' + presetsHtml + '
' + scrubberHtml;
+
+ // Setup scrubber interaction
+ setupScrubber(panelIdx);
+
+ // Preload all slice images for smooth scrolling
+ slices.forEach(s => {
+ const img = new Image();
+ img.src = getImageUrlWithWL(s.id, seriesId, null, null);
+ });
+
+ // Start at middle slice
+ const midSlice = Math.floor(slices.length / 2);
+ goToSlice(panelIdx, midSlice);
+}
+
+function update3DCrosshairs() {
+ if (!is3DMode) return;
+
+ const getData = (p) => {
+ if (!p || !p.slices.length) return null;
+ const s = p.slices[p.currentSlice];
+ // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz"
+ let rowVec = [1,0,0], colVec = [0,1,0];
+ if (s.image_orientation) {
+ const parts = s.image_orientation.split('\\').map(Number);
+ if (parts.length === 6) {
+ rowVec = [parts[0], parts[1], parts[2]];
+ colVec = [parts[3], parts[4], parts[5]];
+ }
+ }
+
+ // Compute CENTER of slice (not corner)
+ const psRow = s.pixel_spacing_row || 0.5;
+ const psCol = s.pixel_spacing_col || 0.5;
+ const halfWidth = (s.cols / 2) * psCol;
+ const halfHeight = (s.rows / 2) * psRow;
+
+ const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0];
+ const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1];
+ const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2];
+
+ return {
+ pos_x: s.pos_x,
+ pos_y: s.pos_y,
+ pos_z: s.pos_z,
+ slice_loc: s.slice_location,
+ center_x: centerX,
+ center_y: centerY,
+ center_z: centerZ,
+ rows: s.rows,
+ cols: s.cols,
+ psRow: psRow,
+ psCol: psCol,
+ rowVec: rowVec,
+ colVec: colVec
+ };
+ };
+
+ // Get reference data from FIRST slice of each series (fixed reference frame)
+ const getRefData = (panel) => {
+ if (!panel || !panel.slices || !panel.slices.length) return null;
+ const s = panel.slices[0]; // Use first slice as reference
+ return {
+ pos_x: s.pos_x,
+ pos_y: s.pos_y,
+ pos_z: s.pos_z,
+ rows: s.rows,
+ cols: s.cols,
+ psRow: s.pixel_spacing_row,
+ psCol: s.pixel_spacing_col
+ };
+ };
+
+ const sagPanel = panels.find(p => p.orientation === 'SAG');
+ const axPanel = panels.find(p => p.orientation === 'AX');
+ const corPanel = panels.find(p => p.orientation === 'COR');
+
+ // Current slice_location from each panel (through-plane position)
+ const sagLoc = sagPanel?.slices[sagPanel.currentSlice]?.slice_location || 0; // X
+ const axLoc = axPanel?.slices[axPanel.currentSlice]?.slice_location || 0; // Z
+ const corLoc = corPanel?.slices[corPanel.currentSlice]?.slice_location || 0; // Y
+
+ // Reference frames (first slice of each series)
+ const sagRef = getRefData(sagPanel);
+ const axRef = getRefData(axPanel);
+ const corRef = getRefData(corPanel);
+
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ if (!p.slices.length || !p.orientation) return;
+
+ const div = document.getElementById('panel-' + idx);
+ const img = div.querySelector('.panel-content img');
+ const hLine = div.querySelector('.crosshair-h');
+ const vLine = div.querySelector('.crosshair-v');
+
+ console.log("Panel " + idx + ": naturalWidth=" + img.naturalWidth);
+ if (!img.naturalWidth) {
+ hLine.style.display = 'none';
+ vLine.style.display = 'none';
+ return;
+ }
+
+ // DEBUG: Fixed position at top-left corner
+ hLine.style.top = '0px';
+ hLine.style.display = 'block';
+ vLine.style.left = '0px';
+ vLine.style.display = 'block';
+ });
+}
+
+function goToSlice(panelIdx, sliceIdx) {
+ const panel = panels[panelIdx];
+ if (!panel.slices.length) return;
+ panel.currentSlice = sliceIdx;
+
+ const div = document.getElementById('panel-' + panelIdx);
+ const img = div.querySelector('.panel-content img');
+ img.onload = () => detectImageBrightness(img, panelIdx);
+ img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId);
+
+ // Clear rectangle when changing slice
+ div.querySelector('.rect-overlay').style.display = 'none';
+
+ div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx));
+
+ // Update scrubber position
+ updateScrubber(panelIdx, sliceIdx);
+
+ updateOverlay(panelIdx);
+
+ // Update crosshairs in 3D mode
+ if (is3DMode) {
+ setTimeout(update3DCrosshairs, 50);
+ }
+
+ if (document.getElementById('syncScroll').checked && !is3DMode) {
+ const loc = panel.slices[sliceIdx].slice_location;
+ panels.forEach((p, i) => {
+ if (i !== panelIdx && p.slices.length) {
+ const closest = p.slices.reduce((prev, curr, idx) =>
+ Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0);
+ if (p.currentSlice !== closest) {
+ p.currentSlice = closest;
+ const pDiv = document.getElementById('panel-' + i);
+ pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId);
+ pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest));
+ updateOverlay(i);
+ }
+ }
+ });
+ }
+}
+
+// Track hovered panel for keyboard zoom
+document.addEventListener('mousemove', (e) => {
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ hoveredPanel = idx;
+ }
+ }
+ });
+}, { passive: true });
+
+document.addEventListener('wheel', e => {
+ if (!panels.length) return;
+
+ // Find which panel the mouse is over
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+
+ if (targetPanel < 0) return;
+ hoveredPanel = targetPanel;
+
+ // Track cursor relative to wrapper (for zoom-to-cursor)
+ // Account for current zoom since getBoundingClientRect returns transformed bounds
+ const div = document.getElementById('panel-' + targetPanel);
+ const wrapper = div.querySelector('.img-wrapper');
+ const wrapperRect = wrapper.getBoundingClientRect();
+ const orientation = getPanelOrientation(targetPanel);
+ const currentZoom = zoomLevels[zoomState[orientation].level];
+ cursorX = (e.clientX - wrapperRect.left) / currentZoom;
+ cursorY = (e.clientY - wrapperRect.top) / currentZoom;
+
+ // Shift+wheel = zoom
+ if (e.shiftKey) {
+ e.preventDefault();
+ const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) {
+ zoomIn(targetPanel);
+ } else if (delta > 0) {
+ zoomOut(targetPanel);
+ }
+ return;
+ }
+
+ // Regular wheel = scroll slices
+ const delta = e.deltaY > 0 ? 1 : -1;
+ const p = panels[targetPanel];
+ if (!p.slices.length) return;
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx);
+}, { passive: false });
+
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ if (document.getElementById('tourOverlay').classList.contains('show')) {
+ endTour();
+ } else if (document.getElementById('helpModal').classList.contains('show')) {
+ toggleHelp();
+ } else {
+ clearRect();
+ }
+ return;
+ }
+ if (!panels.length) return;
+
+ // +/- for zoom (affects hovered panel's orientation group)
+ if (e.key === '+' || e.key === '=') {
+ e.preventDefault();
+ zoomIn(hoveredPanel);
+ return;
+ }
+ if (e.key === '-' || e.key === '_') {
+ e.preventDefault();
+ zoomOut(hoveredPanel);
+ return;
+ }
+
+ // Arrow keys for slice navigation
+ let delta = 0;
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1;
+ if (delta === 0) return;
+ e.preventDefault();
+ const p = panels[0];
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(0, newIdx);
+});
+
+// Cancel drawing if mouse leaves window
+document.addEventListener('mouseup', (e) => {
+ isDrawing = false;
+ if (isPanning) {
+ isPanning = false;
+ // Restore transition
+ document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = '');
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning'));
+ }
+});
+
+// Shift+click pan
+document.addEventListener('mousedown', (e) => {
+ if (e.button !== 0 || !e.shiftKey) return;
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ const state = zoomState[orientation];
+ if (state.level === 0) return; // no pan at 1x zoom
+
+ isPanning = true;
+ panOrientation = orientation;
+ panStartMouseX = e.clientX;
+ panStartMouseY = e.clientY;
+ panStartPanX = state.panX;
+ panStartPanY = state.panY;
+ // Disable transition during pan for smooth movement
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (div) div.querySelector('.img-wrapper').style.transition = 'none';
+ }
+ });
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning'));
+});
+
+document.addEventListener('mousemove', (e) => {
+ if (!isPanning || !panOrientation) return;
+
+ const state = zoomState[panOrientation];
+ const zoom = zoomLevels[state.level];
+ // With transform: scale(zoom) translate(panX, panY), translate values are scaled
+ // Divide by zoom for 1:1 screen-to-image movement
+ const dx = (e.clientX - panStartMouseX) / zoom;
+ const dy = (e.clientY - panStartMouseY) / zoom;
+ state.panX = panStartPanX + dx;
+ state.panY = panStartPanY + dy;
+ applyZoom(panOrientation);
+});
+
+// Double-click to reset zoom
+document.addEventListener('dblclick', (e) => {
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ resetZoom(orientation);
+});
+
+// Ctrl+click for Window/Level adjustment
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const panel = panels[targetPanel];
+ if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return;
+
+ isAdjustingWL = true;
+ isDrawing = false; // Prevent rect drawing
+ document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none");
+ wlPanel = targetPanel;
+ wlStartX = e.clientX;
+ wlStartY = e.clientY;
+ wlStartWc = wlState[panel.seriesId].wc;
+ wlStartWw = wlState[panel.seriesId].ww;
+ document.body.style.cursor = "crosshair";
+
+ // Show hint
+ const hint = document.getElementById('wlHint');
+ hint.style.left = (e.clientX + 15) + 'px';
+ hint.style.top = (e.clientY - 10) + 'px';
+ hint.classList.add('show');
+});
+
+document.addEventListener("mousemove", (e) => {
+ if (!isAdjustingWL || wlPanel < 0) return;
+
+ const panel = panels[wlPanel];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (!state) return;
+
+ // Horizontal = width, Vertical = center
+ const dx = e.clientX - wlStartX;
+ const dy = e.clientY - wlStartY;
+
+ state.ww = Math.max(1, wlStartWw + dx * 2);
+ state.wc = wlStartWc - dy * 2; // invert: drag up = brighter
+ state.adjusted = true;
+
+ // Update overlay C/W values in real-time
+ const div = document.getElementById("panel-" + wlPanel);
+ const wcEl = div.querySelector(".overlay-wc");
+ const wwEl = div.querySelector(".overlay-ww");
+ if (wcEl) wcEl.textContent = Math.round(state.wc);
+ if (wwEl) wwEl.textContent = Math.round(state.ww);
+
+ // Debounce image reload
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ wlDebounceTimer = setTimeout(() => {
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ }, 150);
+});
+
+document.addEventListener("mouseup", (e) => {
+ if (isAdjustingWL) {
+ isAdjustingWL = false;
+ document.body.style.cursor = "";
+ document.getElementById('wlHint').classList.remove('show');
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ if (wlPanel >= 0) {
+ reloadPanelImages(wlPanel);
+ }
+ wlPanel = -1;
+ }
+});
+
+// Track right-click for double-click detection
+let lastRightClickTime = 0;
+let lastRightClickPanel = -1;
+
+// Double right-click to reset Window/Level
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2) return;
+
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0));
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const now = Date.now();
+ if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) {
+ // Double right-click detected - reset W/L
+ resetWL(targetPanel);
+ lastRightClickTime = 0;
+ lastRightClickPanel = -1;
+ e.preventDefault();
+ return;
+ }
+ lastRightClickTime = now;
+ lastRightClickPanel = targetPanel;
+});
+
+// Update crosshairs on window resize
+// Prevent context menu on panels for right-click W/L adjustment
+document.addEventListener("contextmenu", (e) => {
+ if (!e.target.closest("#panels")) return;
+ e.preventDefault();
+});
+
+window.addEventListener('resize', () => {
+ if (is3DMode) update3DCrosshairs();
+});
+
+// W/L Preset functions
+function applyWLPreset(el) {
+ const panelIdx = parseInt(el.dataset.panel);
+ const wcAttr = el.dataset.wc;
+ const wwAttr = el.dataset.ww;
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+
+ // Update wlState - null means reset to original
+ if (wcAttr === 'null' || wwAttr === 'null') {
+ wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc;
+ wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw;
+ wlState[panel.seriesId].adjusted = false;
+ } else {
+ wlState[panel.seriesId].wc = parseInt(wcAttr);
+ wlState[panel.seriesId].ww = parseInt(wwAttr);
+ wlState[panel.seriesId].adjusted = true;
+ }
+
+ // Update active preset
+ const container = el.closest('.thumbnails');
+ container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active'));
+ el.classList.add('active');
+
+ // Reload image
+ reloadPanelImages(panelIdx);
+}
+
+function setupScrubber(panelIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const track = scrubber.querySelector('.scrubber-track');
+ let isDragging = false;
+
+ const updateFromPosition = (e) => {
+ const rect = track.getBoundingClientRect();
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
+ const pct = x / rect.width;
+ const sliceCount = panels[panelIdx].slices.length;
+ const sliceIdx = Math.round(pct * (sliceCount - 1));
+ goToSlice(panelIdx, sliceIdx);
+ };
+
+ track.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ updateFromPosition(e);
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (isDragging) updateFromPosition(e);
+ });
+
+ document.addEventListener('mouseup', () => {
+ isDragging = false;
+ });
+}
+
+function updateScrubber(panelIdx, sliceIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const sliceCount = panels[panelIdx].slices.length;
+ const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0;
+
+ const fill = scrubber.querySelector('.scrubber-fill');
+ const handle = scrubber.querySelector('.scrubber-handle');
+ const current = scrubber.querySelector('.scrubber-current');
+
+ if (fill) fill.style.width = pct + '%';
+ if (handle) handle.style.left = pct + '%';
+ if (current) current.textContent = sliceIdx + 1;
+}
+
+init();
diff --git a/static/viewer_js b/static/viewer_js
new file mode 100644
index 0000000..e439fca
--- /dev/null
+++ b/static/viewer_js
@@ -0,0 +1,1646 @@
+let studies = [];
+let currentStudy = null;
+let studyInfo = {};
+let seriesList = [];
+let panels = [];
+let panelCount = 0;
+let is3DMode = false;
+let seriesListByOrientation = { SAG: [], AX: [], COR: [] };
+let tokenParam = ''; // Will be set from URL if present
+
+// W/L presets for common viewing windows
+const wlPresets = [
+ { name: 'Default', wc: null, ww: null },
+ { name: 'Brain', wc: 40, ww: 80 },
+ { name: 'Subdural', wc: 80, ww: 200 },
+ { name: 'Bone', wc: 500, ww: 2000 },
+ { name: 'Stroke', wc: 40, ww: 40 },
+ { name: 'Soft', wc: 50, ww: 400 }
+];
+
+// Detect if image background is light (for overlay color adjustment)
+function detectImageBrightness(img, panelIdx) {
+ const div = document.getElementById('panel-' + panelIdx);
+ if (!div || !img.complete || !img.naturalWidth) return;
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const sampleSize = 50;
+ canvas.width = sampleSize;
+ canvas.height = sampleSize;
+
+ // Sample top-left corner (where overlay text appears)
+ ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize);
+ const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
+
+ let total = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ total += (data[i] + data[i+1] + data[i+2]) / 3;
+ }
+ const avgBrightness = total / (data.length / 4);
+
+ // Toggle light-bg class based on brightness threshold
+ div.classList.toggle('light-bg', avgBrightness > 160);
+}
+
+function addToken(url) {
+ if (!tokenParam) return url;
+ return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam;
+}
+
+// Rectangle drawing state
+let isDrawing = false;
+let startX = 0, startY = 0;
+let currentRect = null;
+let activePanel = null;
+
+// Window/Level adjustment state
+let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw }
+let isAdjustingWL = false;
+let wlStartX = 0, wlStartY = 0;
+let wlStartWc = 0, wlStartWw = 0;
+let wlDebounceTimer = null;
+let wlPanel = -1;
+
+function getImageUrl(sliceId, seriesId) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) {
+ params.push("wc=" + Math.round(wlState[seriesId].wc));
+ params.push("ww=" + Math.round(wlState[seriesId].ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function getImageUrlWithWL(sliceId, seriesId, wc, ww) {
+ let url = "/image/" + sliceId;
+ const params = [];
+ if (tokenParam) params.push("token=" + tokenParam);
+ if (wc !== null && ww !== null) {
+ params.push("wc=" + Math.round(wc));
+ params.push("ww=" + Math.round(ww));
+ }
+ if (params.length) url += "?" + params.join("&");
+ return url;
+}
+
+function initWLState(seriesId, slices) {
+ if (!wlState[seriesId] && slices.length > 0) {
+ const s = slices[0];
+ wlState[seriesId] = { adjusted: false,
+ wc: s.window_center || 128,
+ ww: s.window_width || 256,
+ originalWc: s.window_center || 128,
+ originalWw: s.window_width || 256
+ };
+ }
+}
+
+function resetWL(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (state) {
+ state.wc = state.originalWc;
+ state.ww = state.originalWw;
+ state.adjusted = false;
+ reloadPanelImages(panelIdx);
+ }
+}
+
+function reloadPanelImages(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ // Reload thumbnails too
+ const thumbs = div.querySelectorAll(".thumb");
+ thumbs.forEach((t, i) => {
+ t.src = getImageUrl(panel.slices[i].id, panel.seriesId);
+ });
+ updateOverlay(panelIdx);
+}
+
+function updateOverlay(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel) return;
+ const div = document.getElementById("panel-" + panelIdx);
+ if (!div) return;
+
+ // Get series info
+ const series = seriesList.find(s => s.id === panel.seriesId) ||
+ (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null);
+ const seriesName = series ? series.series_desc : "";
+
+ // Get slice info
+ const slice = panel.slices[panel.currentSlice];
+
+ // Get W/L info
+ let wc = "", ww = "";
+ let adjusted = false;
+ if (panel.seriesId && wlState[panel.seriesId]) {
+ const state = wlState[panel.seriesId];
+ if (state.adjusted) {
+ wc = Math.round(state.wc);
+ ww = Math.round(state.ww);
+ adjusted = true;
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+ } else if (slice) {
+ wc = Math.round(slice.window_center || 0);
+ ww = Math.round(slice.window_width || 0);
+ }
+
+ // Get zoom level
+ const orientation = panel.orientation || "AX";
+ const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100;
+
+ // Update all overlay elements
+ const q = s => div.querySelector(s);
+
+ // Top left - patient/study info
+ if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " ");
+ if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : "";
+ if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || "";
+ if (q(".overlay-series")) q(".overlay-series").textContent = seriesName;
+ if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : "";
+ if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : "";
+
+ // Top right - technical info
+ if (q(".overlay-datetime")) {
+ let dt = "";
+ if (studyInfo.study_date) {
+ dt = studyInfo.study_date;
+ if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4);
+ }
+ q(".overlay-datetime").textContent = dt;
+ }
+ if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || "";
+ if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : "";
+ if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : "";
+
+ const wcEl = q(".overlay-wc");
+ const wwEl = q(".overlay-ww");
+ if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); }
+ if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); }
+
+ if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : "";
+
+ // Orientation markers based on image_orientation
+ updateOrientationMarkers(div, slice, orientation);
+}
+
+function updateOrientationMarkers(div, slice, orientationType) {
+ const left = div.querySelector(".overlay-orient-left");
+ const right = div.querySelector(".overlay-orient-right");
+ const top = div.querySelector(".overlay-orient-top");
+ const bottom = div.querySelector(".overlay-orient-bottom");
+
+ // Default markers based on orientation type
+ let markers = { left: "", right: "", top: "", bottom: "" };
+
+ if (orientationType === "AX") {
+ markers = { left: "R", right: "L", top: "A", bottom: "P" };
+ } else if (orientationType === "SAG") {
+ markers = { left: "A", right: "P", top: "S", bottom: "I" };
+ } else if (orientationType === "COR") {
+ markers = { left: "R", right: "L", top: "S", bottom: "I" };
+ }
+
+ // TODO: Parse image_orientation DICOM tag for exact orientation if needed
+
+ if (left) left.textContent = markers.left;
+ if (right) right.textContent = markers.right;
+ if (top) top.textContent = markers.top;
+ if (bottom) bottom.textContent = markers.bottom;
+}
+
+// Zoom state - shared by orientation type
+const zoomLevels = [1, 1.5, 2, 3, 4];
+let zoomState = {
+ AX: { level: 0, panX: 0, panY: 0 },
+ SAG: { level: 0, panX: 0, panY: 0 },
+ COR: { level: 0, panX: 0, panY: 0 }
+};
+let hoveredPanel = 0;
+let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper
+let scrollAccumulator = 0; // for slower slice scrolling
+
+function toggleHelp() {
+ document.getElementById('helpModal').classList.toggle('show');
+}
+
+// Tour functionality
+const tourSteps = [
+ {
+ target: () => document.getElementById('header'),
+ title: 'Welcome to Inou',
+ text: '異能
"extraordinary ability"
Explore medical imaging with AI assistance.
Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('button[onclick="setPanels(1)"]'),
+ title: 'Panel Layout',
+ text: 'Switch between 1, 2, or 3 panels to compare different series side by side.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('btn3d'),
+ title: '3D Crosshair Mode',
+ text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('helpBtn'),
+ title: 'Keyboard Shortcuts',
+ text: 'Click here for a quick reference of all keyboard and mouse controls.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.querySelector('.panel-content img'),
+ title: 'Select a Region',
+ text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.',
+ pos: 'bottom'
+ },
+ {
+ target: () => document.getElementById('rectInfo'),
+ title: 'AI Communication',
+ text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.',
+ pos: 'bottom'
+ }
+];
+
+let tourIndex = 0;
+
+function startTour() {
+ tourIndex = 0;
+ document.getElementById('tourOverlay').classList.add('show');
+ showTourStep();
+}
+
+function endTour() {
+ document.getElementById('tourOverlay').classList.remove('show');
+ localStorage.setItem('tourSeen', 'true');
+}
+
+function showTourStep() {
+ const step = tourSteps[tourIndex];
+ const target = step.target();
+ if (!target) { nextTourStep(); return; }
+
+ const rect = target.getBoundingClientRect();
+ const spotlight = document.getElementById('tourSpotlight');
+ const tooltip = document.getElementById('tourTooltip');
+
+ // Position spotlight
+ const pad = 8;
+ spotlight.style.left = (rect.left - pad) + 'px';
+ spotlight.style.top = (rect.top - pad) + 'px';
+ spotlight.style.width = (rect.width + pad * 2) + 'px';
+ spotlight.style.height = (rect.height + pad * 2) + 'px';
+
+ // Build tooltip
+ const isLastStep = tourIndex >= tourSteps.length - 1;
+ tooltip.innerHTML = '' + step.title + '
' + step.text + '
' +
+ '' +
+ (isLastStep ? '' : '') +
+ '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' +
+ '' +
+ '
';
+
+ // Position tooltip
+ const ttWidth = tourIndex === 0 ? 420 : 300;
+ const ttHeight = tourIndex === 0 ? 280 : 150;
+ let ttLeft = rect.left + rect.width / 2 - ttWidth / 2;
+ ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft));
+ tooltip.style.left = ttLeft + 'px';
+ tooltip.style.width = ttWidth + 'px';
+
+ // Welcome screen: center vertically
+ if (tourIndex === 0) {
+ tooltip.style.top = '50%';
+ tooltip.style.transform = 'translateY(-50%)';
+ tooltip.style.left = '50%';
+ tooltip.style.marginLeft = (-ttWidth / 2) + 'px';
+ return;
+ }
+ tooltip.style.transform = 'none';
+ tooltip.style.marginLeft = '0';
+
+ // Determine best vertical position
+ const spaceBelow = window.innerHeight - rect.bottom - 20;
+ const spaceAbove = rect.top - 20;
+ const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove;
+
+ tooltip.style.bottom = 'auto';
+ tooltip.style.top = 'auto';
+
+ if (placeBelow) {
+ let ttTop = rect.bottom + 15;
+ ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20);
+ tooltip.style.top = ttTop + 'px';
+ } else {
+ let ttTop = rect.top - ttHeight - 15;
+ ttTop = Math.max(10, ttTop);
+ tooltip.style.top = ttTop + 'px';
+ }
+}
+
+function nextTourStep() {
+ tourIndex++;
+ if (tourIndex >= tourSteps.length) {
+ endTour();
+ } else {
+ showTourStep();
+ }
+}
+
+// Pan state
+let isPanning = false;
+let panStartMouseX = 0, panStartMouseY = 0;
+let panStartPanX = 0, panStartPanY = 0;
+let panOrientation = null;
+
+function getCurrentImageRef(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return '';
+ const slice = panel.slices[panel.currentSlice];
+ const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown';
+ const study = studies.find(s => s.id == document.getElementById('studySelect').value);
+ const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown';
+ return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number;
+}
+
+async function init() {
+ // Create W/L hint element
+ const wlHint = document.createElement('div');
+ wlHint.id = 'wlHint';
+ wlHint.textContent = 'Image updates after 0.3s';
+ document.body.appendChild(wlHint);
+
+ // Extract token from URL for subsequent API calls
+ const params = new URLSearchParams(window.location.search);
+ tokenParam = params.get('token') || '';
+
+ const res = await fetch(addToken('/api/studies'));
+ studies = await res.json();
+ const sel = document.getElementById('studySelect');
+ studies.forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ opt.textContent = s.study_date + ' - ' + s.study_desc;
+ sel.appendChild(opt);
+ });
+
+ if (studies.length > 0) sel.selectedIndex = 0;
+
+ // Deep link by study/series GUID
+ const urlStudy = params.get('study');
+ const urlSeries = params.get('series');
+
+ if (urlStudy) {
+ const idx = studies.findIndex(s => s.id === urlStudy);
+ if (idx >= 0) sel.selectedIndex = idx;
+ }
+
+ if (studies.length > 0) {
+ await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise
+ if (urlSeries && seriesList.length > 0) {
+ const idx = seriesList.findIndex(s => s.id === urlSeries);
+ if (idx >= 0 && panels[0]) {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option
+ await loadSeries(0, seriesList[idx].id);
+ }
+ }
+ }
+
+ // Auto-start tour for first-time users
+ if (!localStorage.getItem('tourSeen')) {
+ setTimeout(startTour, 800);
+ }
+}
+
+async function addPanelEmpty() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+}
+
+async function loadStudy(numPanels = 2) {
+ const studyId = document.getElementById('studySelect').value;
+ const res = await fetch(addToken('/api/series?study=' + studyId));
+ seriesList = await res.json();
+ // Fetch study info for overlay
+ const infoRes = await fetch(addToken("/api/studies?study=" + studyId));
+ studyInfo = await infoRes.json();
+ is3DMode = false;
+
+ // Smart UI: show/hide elements based on series count
+ const seriesCount = seriesList.length;
+ const btn2panels = document.getElementById('btn2panels');
+ const btn3panels = document.getElementById('btn3panels');
+ const btn3d = document.getElementById('btn3d');
+ const syncLabel = document.getElementById('syncLabel');
+
+ if (seriesCount === 1) {
+ // Single series: hide multi-panel options, 3D, sync
+ btn2panels.style.display = 'none';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ numPanels = 1; // Force single panel
+ } else if (seriesCount === 2) {
+ // Two series: hide 3-panel, 3D, sync
+ btn2panels.style.display = '';
+ btn3panels.style.display = 'none';
+ btn3d.style.display = 'none';
+ syncLabel.style.display = 'none';
+ if (numPanels > 2) numPanels = 2;
+ } else {
+ // 3+ series: show all, check 3D availability
+ btn2panels.style.display = '';
+ btn3panels.style.display = '';
+ btn3d.style.display = '';
+ syncLabel.style.display = '';
+
+ // Check if 3D mode is available (has SAG, AX, and COR)
+ const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG'));
+ const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX'));
+ const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR'));
+ btn3d.disabled = !(hasSag && hasAx && hasCor);
+ btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode';
+ }
+
+ setPanels(numPanels);
+}
+
+async function set3DMode() {
+ const studyId = document.getElementById('studySelect').value;
+ if (!studyId) return;
+
+ is3DMode = true;
+ document.getElementById('syncScroll').checked = false;
+
+ // Fetch series for each orientation
+ const [sagRes, axRes, corRes] = await Promise.all([
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')),
+ fetch(addToken('/api/series?study=' + studyId + '&orientation=COR'))
+ ]);
+ seriesListByOrientation.SAG = await sagRes.json() || [];
+ seriesListByOrientation.AX = await axRes.json() || [];
+ seriesListByOrientation.COR = await corRes.json() || [];
+
+ // Clear and create 3 panels
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+
+ await add3DPanel(0, 'SAG', seriesListByOrientation.SAG);
+ await add3DPanel(1, 'AX', seriesListByOrientation.AX);
+ await add3DPanel(2, 'COR', seriesListByOrientation.COR);
+}
+
+function pickBestSeries(seriesOptions) {
+ if (!seriesOptions || !seriesOptions.length) return null;
+ // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc)
+ let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc));
+ if (t1plus) return t1plus.id;
+ // Then T2
+ let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc));
+ if (t2) return t2.id;
+ // Then T1 (without contrast)
+ let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc));
+ if (t1) return t1.id;
+ // Fallback to first
+ return seriesOptions[0].id;
+}
+
+async function add3DPanel(idx, orientation, seriesOptions) {
+ panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+ div.innerHTML =
+ '' +
+ '' +
+ '
![]()
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+
+ // Auto-select best series (T1+ > T2 > T1 > first)
+ const bestSeriesId = pickBestSeries(seriesOptions);
+ if (bestSeriesId) {
+ div.querySelector('select').value = bestSeriesId;
+ await loadSeries(idx, bestSeriesId);
+ }
+}
+
+function setPanels(count) {
+ is3DMode = false;
+ document.getElementById('panels').innerHTML = '';
+ panels = [];
+ panelCount = 0;
+ for (let i = 0; i < count; i++) {
+ addPanel();
+ }
+}
+
+function getImageCoords(e, img) {
+ const rect = img.getBoundingClientRect();
+ const scaleX = img.naturalWidth / rect.width;
+ const scaleY = img.naturalHeight / rect.height;
+ const x = Math.round((e.clientX - rect.left) * scaleX);
+ const y = Math.round((e.clientY - rect.top) * scaleY);
+ return { x, y, rect, scaleX, scaleY };
+}
+
+function getPanelOrientation(panelIdx) {
+ const panel = panels[panelIdx];
+ if (!panel || !panel.slices.length) return 'AX';
+ // In 3D mode, orientation is stored on panel
+ if (panel.orientation) return panel.orientation;
+ // Otherwise, derive from series description
+ const series = seriesList.find(s => s.id == panel.seriesId);
+ if (series) {
+ const desc = series.series_desc.toUpperCase();
+ if (desc.includes('SAG')) return 'SAG';
+ if (desc.includes('COR')) return 'COR';
+ }
+ return 'AX'; // default
+}
+
+function applyZoom(orientation) {
+ const state = zoomState[orientation];
+ const zoom = zoomLevels[state.level];
+ panels.forEach((p, idx) => {
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (!div) return;
+ const wrapper = div.querySelector('.img-wrapper');
+ const content = div.querySelector('.panel-content');
+ wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)';
+ content.classList.toggle('zoomed', state.level > 0);
+ }
+ });
+}
+
+function zoomIn(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level < zoomLevels.length - 1) {
+ state.level++;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function zoomOut(panelIdx) {
+ const orientation = getPanelOrientation(panelIdx);
+ const state = zoomState[orientation];
+ if (state.level > 0) {
+ state.level--;
+ // Keep pan at 0 to center the image
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+ }
+}
+
+function resetZoom(orientation) {
+ const state = zoomState[orientation];
+ state.level = 0;
+ state.panX = 0;
+ state.panY = 0;
+ applyZoom(orientation);
+}
+
+async function addPanel() {
+ const idx = panelCount++;
+ const panel = { idx, seriesId: null, slices: [], currentSlice: 0 };
+ panels.push(panel);
+
+ const div = document.createElement('div');
+ div.className = 'panel';
+ div.id = 'panel-' + idx;
+
+ // Series header: show dropdown only if multiple series
+ let headerContent;
+ if (seriesList.length === 1) {
+ headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')';
+ } else {
+ headerContent = '';
+ }
+
+ div.innerHTML =
+ '' +
+ '' +
+ '';
+ document.getElementById('panels').appendChild(div);
+
+ const img = div.querySelector('.panel-content img');
+ const wrapper = div.querySelector('.img-wrapper');
+ const rectOverlay = div.querySelector('.rect-overlay');
+
+ // Mouse move - show coordinates
+ img.addEventListener('mousemove', e => {
+ const { x, y } = getImageCoords(e, img);
+ document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y;
+
+ if (isDrawing && activePanel === idx) {
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = (e.clientX - rect.left);
+ const curY = (e.clientY - rect.top);
+ const sx = Math.min(startX, curX);
+ const sy = Math.min(startY, curY);
+ const w = Math.abs(curX - startX);
+ const h = Math.abs(curY - startY);
+ rectOverlay.style.left = sx + 'px';
+ rectOverlay.style.top = sy + 'px';
+ rectOverlay.style.width = w + 'px';
+ rectOverlay.style.height = h + 'px';
+ rectOverlay.style.display = 'block';
+
+ // Update coords in image space
+ const x1 = Math.round(Math.min(startX, curX) * scaleX);
+ const y1 = Math.round(Math.min(startY, curY) * scaleY);
+ const x2 = Math.round(Math.max(startX, curX) * scaleX);
+ const y2 = Math.round(Math.max(startY, curY) * scaleY);
+ const imgRef = getCurrentImageRef(activePanel);
+ document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ }
+ });
+
+ img.addEventListener('mouseleave', () => {
+ document.getElementById('coordDisplay').textContent = 'x: -, y: -';
+ });
+
+
+ // Mouse down - start drawing
+ img.addEventListener('mousedown', e => {
+ if (e.button !== 0) return; // Only left-click for drawing
+ if (e.shiftKey) return; // Shift+drag is for panning
+ e.preventDefault();
+ isDrawing = true;
+ activePanel = idx;
+ const rect = img.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+ rectOverlay.style.display = 'none';
+ });
+
+
+ // Mouse up - finish drawing
+ img.addEventListener('mouseup', e => {
+ if (isDrawing && activePanel === idx) {
+ isDrawing = false;
+ const { rect, scaleX, scaleY } = getImageCoords(e, img);
+ const curX = e.clientX - rect.left;
+ const curY = e.clientY - rect.top;
+ if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) {
+ currentRect = {
+ panelIdx: idx,
+ x1: Math.round(Math.min(startX, curX) * scaleX),
+ y1: Math.round(Math.min(startY, curY) * scaleY),
+ x2: Math.round(Math.max(startX, curX) * scaleX),
+ y2: Math.round(Math.max(startY, curY) * scaleY)
+ };
+ const imgRef = getCurrentImageRef(idx);
+ document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')';
+ document.getElementById('rectInfo').style.display = 'block';
+ } else {
+ rectOverlay.style.display = 'none';
+ }
+ }
+ });
+
+ if (idx < seriesList.length) {
+ // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts
+ const preferred = pickDefaultSeries(seriesList, idx);
+ if (preferred) {
+ const select = div.querySelector('select');
+ if (select) select.value = preferred.id;
+ loadSeries(idx, preferred.id);
+ }
+ }
+}
+
+function pickDefaultSeries(series, panelIdx) {
+ // Score each series - lower is better
+ const scored = series.map(s => {
+ const desc = s.series_desc.toUpperCase();
+ let score = 100;
+
+ // Strongly prefer structural sequences
+ if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50;
+ if (desc.includes('T1')) score -= 40;
+ if (desc.includes('FLAIR')) score -= 35;
+
+ // Prefer axial for comparison
+ if (desc.includes('AX')) score -= 20;
+
+ // Avoid diffusion/DWI/DTI
+ if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100;
+ if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80;
+ if (desc.includes('ADC') || desc.includes('TRACE')) score += 60;
+
+ // Prefer moderate slice counts (20-50 is typical for structural)
+ if (s.slice_count > 200) score += 50;
+ if (s.slice_count > 500) score += 50;
+
+ return { ...s, score };
+ });
+
+ // Sort by score
+ scored.sort((a, b) => a.score - b.score);
+
+ // For panel 0, pick best. For panel 1+, pick next best with SAME orientation
+ if (panelIdx === 0) {
+ return scored[0];
+ } else {
+ // Get orientation of first panel's pick
+ const firstPick = scored[0];
+ const firstDesc = firstPick.series_desc.toUpperCase();
+ let firstOrientation = 'AX';
+ if (firstDesc.includes('SAG')) firstOrientation = 'SAG';
+ else if (firstDesc.includes('COR')) firstOrientation = 'COR';
+
+ // Find next best with same orientation (excluding first pick)
+ const sameOrientation = scored.filter(s => {
+ if (s.id === firstPick.id) return false;
+ const desc = s.series_desc.toUpperCase();
+ if (firstOrientation === 'SAG') return desc.includes('SAG');
+ if (firstOrientation === 'COR') return desc.includes('COR');
+ return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR'));
+ });
+
+
+ return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0];
+ }
+}
+
+function showRectFromInput() {
+ const input = document.getElementById('rectCoords').value;
+ const debug = document.getElementById('debugInfo');
+ debug.textContent = 'Parsing: ' + input;
+
+ // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)"
+ const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/);
+ if (fullMatch) {
+ const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch;
+ debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum;
+
+ // Find matching study
+ const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim()));
+ if (!study) {
+ debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim();
+ debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', ');
+ return;
+ }
+ debug.textContent = 'Found study id=' + study.id;
+
+ document.getElementById('studySelect').value = study.id;
+ fetch(addToken('/api/series?study=' + study.id))
+ .then(res => res.json())
+ .then(series => {
+ seriesList = series;
+ debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', ');
+ const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim());
+ if (!targetSeries) {
+ debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"';
+ return;
+ }
+ debug.textContent = 'Found series id=' + targetSeries.id;
+ setPanels(1);
+ setTimeout(() => {
+ const panel = document.getElementById('panel-0');
+ const select = panel.querySelector('select');
+ if (select) select.value = targetSeries.id;
+ loadSeries(0, targetSeries.id).then(() => {
+ const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum));
+ debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum;
+ if (sliceIdx >= 0) {
+ goToSlice(0, sliceIdx);
+ setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100);
+ }
+ });
+ }, 50);
+ });
+ return;
+ }
+
+ debug.textContent = 'No full match, trying coords only...';
+ // Fallback: just coordinates
+ const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/);
+ if (!match) {
+ debug.textContent = 'No coord match either';
+ return;
+ }
+
+ const x1 = parseInt(match[1]), y1 = parseInt(match[2]);
+ const x2 = parseInt(match[3]), y2 = parseInt(match[4]);
+ debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2;
+
+ const panelIdx = activePanel !== null ? activePanel : 0;
+ drawRect(panelIdx, x1, y1, x2, y2);
+}
+
+function drawRect(panelIdx, x1, y1, x2, y2) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+
+ const img = panel.querySelector('.panel-content img');
+ const rectOverlay = panel.querySelector('.rect-overlay');
+
+ const rect = img.getBoundingClientRect();
+ // Divide out zoom since rect overlay is inside the transformed wrapper
+ const orientation = getPanelOrientation(panelIdx);
+ const zoom = zoomLevels[zoomState[orientation].level];
+ const scaleX = rect.width / img.naturalWidth / zoom;
+ const scaleY = rect.height / img.naturalHeight / zoom;
+
+ rectOverlay.style.left = (x1 * scaleX) + 'px';
+ rectOverlay.style.top = (y1 * scaleY) + 'px';
+ rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px';
+ rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px';
+ rectOverlay.style.display = 'block';
+
+ currentRect = { panelIdx, x1, y1, x2, y2 };
+ activePanel = panelIdx;
+}
+
+function copyCoords() {
+ const input = document.getElementById('rectCoords');
+ input.select();
+ document.execCommand('copy');
+}
+
+function clearRect() {
+ document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none');
+ document.getElementById('rectCoords').value = '';
+ currentRect = null;
+}
+
+async function loadSeries(panelIdx, seriesId) {
+ if (!seriesId) return;
+ const res = await fetch(addToken('/api/slices?series=' + seriesId + '&thumbs=false'));
+ const data = await res.json();
+ const slices = data.slices || data;
+ panels[panelIdx].seriesId = seriesId;
+ panels[panelIdx].slices = slices;
+ panels[panelIdx].currentSlice = 0;
+ initWLState(seriesId, slices);
+
+ const panel = document.getElementById('panel-' + panelIdx);
+ const thumbs = panel.querySelector('.thumbnails');
+
+ // Create W/L presets + scrubber
+ const midSliceId = slices[Math.floor(slices.length / 2)]?.id;
+ const presetsHtml = wlPresets.map((p, i) =>
+ '' +
+ '
 + ')
' +
+ '
' + p.name + ' '
+ ).join('');
+
+ const scrubberHtml =
+ '' +
+ '
Slice 1 / ' + slices.length + '
' +
+ '
' +
+ '
1' + slices.length + '
' +
+ '
';
+
+ thumbs.innerHTML = '' + presetsHtml + '
' + scrubberHtml;
+
+ // Setup scrubber interaction
+ setupScrubber(panelIdx);
+
+ // Preload all slice images for smooth scrolling
+ slices.forEach(s => {
+ const img = new Image();
+ img.src = getImageUrlWithWL(s.id, seriesId, null, null);
+ });
+
+ // Start at middle slice
+ const midSlice = Math.floor(slices.length / 2);
+ goToSlice(panelIdx, midSlice);
+}
+
+function update3DCrosshairs() {
+ if (!is3DMode) return;
+
+ const getData = (p) => {
+ if (!p || !p.slices.length) return null;
+ const s = p.slices[p.currentSlice];
+ // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz"
+ let rowVec = [1,0,0], colVec = [0,1,0];
+ if (s.image_orientation) {
+ const parts = s.image_orientation.split('\\').map(Number);
+ if (parts.length === 6) {
+ rowVec = [parts[0], parts[1], parts[2]];
+ colVec = [parts[3], parts[4], parts[5]];
+ }
+ }
+
+ // Compute CENTER of slice (not corner)
+ const psRow = s.pixel_spacing_row || 0.5;
+ const psCol = s.pixel_spacing_col || 0.5;
+ const halfWidth = (s.cols / 2) * psCol;
+ const halfHeight = (s.rows / 2) * psRow;
+
+ const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0];
+ const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1];
+ const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2];
+
+ return {
+ pos_x: s.pos_x,
+ pos_y: s.pos_y,
+ pos_z: s.pos_z,
+ center_x: centerX,
+ center_y: centerY,
+ center_z: centerZ,
+ rows: s.rows,
+ cols: s.cols,
+ psRow: psRow,
+ psCol: psCol,
+ rowVec: rowVec,
+ colVec: colVec
+ };
+ };
+
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
+
+ const sagPanel = panels.find(p => p.orientation === 'SAG');
+ const axPanel = panels.find(p => p.orientation === 'AX');
+ const corPanel = panels.find(p => p.orientation === 'COR');
+
+ const sagData = getData(sagPanel);
+ const axData = getData(axPanel);
+ const corData = getData(corPanel);
+
+ panels.forEach((p, idx) => {
+ if (!p.slices.length || !p.orientation) return;
+
+ const div = document.getElementById('panel-' + idx);
+ const img = div.querySelector('.panel-content img');
+ const hLine = div.querySelector('.crosshair-h');
+ const vLine = div.querySelector('.crosshair-v');
+
+ if (!img.naturalWidth) {
+ hLine.style.display = 'none';
+ vLine.style.display = 'none';
+ return;
+ }
+
+ const myData = getData(p);
+ const rect = img.getBoundingClientRect();
+ // Divide out zoom since crosshairs are inside the transformed wrapper
+ const zoom = zoomLevels[zoomState[p.orientation].level];
+ const scaleX = rect.width / img.naturalWidth / zoom;
+ const scaleY = rect.height / img.naturalHeight / zoom;
+
+ // Build target point from CENTER of other slices
+ // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y
+ let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z;
+ if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x;
+ if (axData && p.orientation !== 'AX') targetZ = axData.center_z;
+ if (corData && p.orientation !== 'COR') targetY = corData.center_y;
+
+ // Offset from corner to target
+ const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z];
+
+ // Project onto row/col directions
+ const vPixel = dot(offset, myData.rowVec) / myData.psCol;
+ const hPixel = dot(offset, myData.colVec) / myData.psRow;
+
+ if (hPixel >= 0 && hPixel <= myData.rows) {
+ hLine.style.top = (hPixel * scaleY) + 'px';
+ hLine.style.display = 'block';
+ } else {
+ hLine.style.display = 'none';
+ }
+
+ if (vPixel >= 0 && vPixel <= myData.cols) {
+ vLine.style.left = (vPixel * scaleX) + 'px';
+ vLine.style.display = 'block';
+ } else {
+ vLine.style.display = 'none';
+ }
+ });
+}
+
+function goToSlice(panelIdx, sliceIdx) {
+ const panel = panels[panelIdx];
+ if (!panel.slices.length) return;
+ panel.currentSlice = sliceIdx;
+
+ const div = document.getElementById('panel-' + panelIdx);
+ const img = div.querySelector('.panel-content img');
+ img.onload = () => detectImageBrightness(img, panelIdx);
+ img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId);
+
+ // Clear rectangle when changing slice
+ div.querySelector('.rect-overlay').style.display = 'none';
+
+ div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx));
+
+ // Update scrubber position
+ updateScrubber(panelIdx, sliceIdx);
+
+ updateOverlay(panelIdx);
+
+ // Update crosshairs in 3D mode
+ if (is3DMode) {
+ setTimeout(update3DCrosshairs, 50);
+ }
+
+ if (document.getElementById('syncScroll').checked && !is3DMode) {
+ const loc = panel.slices[sliceIdx].slice_location;
+ panels.forEach((p, i) => {
+ if (i !== panelIdx && p.slices.length) {
+ const closest = p.slices.reduce((prev, curr, idx) =>
+ Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0);
+ if (p.currentSlice !== closest) {
+ p.currentSlice = closest;
+ const pDiv = document.getElementById('panel-' + i);
+ pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId);
+ pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest));
+ updateOverlay(i);
+ }
+ }
+ });
+ }
+}
+
+// Track hovered panel for keyboard zoom
+document.addEventListener('mousemove', (e) => {
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ hoveredPanel = idx;
+ }
+ }
+ });
+}, { passive: true });
+
+document.addEventListener('wheel', e => {
+ if (!panels.length) return;
+
+ // Find which panel the mouse is over
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+
+ if (targetPanel < 0) return;
+ hoveredPanel = targetPanel;
+
+ // Track cursor relative to wrapper (for zoom-to-cursor)
+ // Account for current zoom since getBoundingClientRect returns transformed bounds
+ const div = document.getElementById('panel-' + targetPanel);
+ const wrapper = div.querySelector('.img-wrapper');
+ const wrapperRect = wrapper.getBoundingClientRect();
+ const orientation = getPanelOrientation(targetPanel);
+ const currentZoom = zoomLevels[zoomState[orientation].level];
+ cursorX = (e.clientX - wrapperRect.left) / currentZoom;
+ cursorY = (e.clientY - wrapperRect.top) / currentZoom;
+
+ // Shift+wheel = zoom
+ if (e.shiftKey) {
+ e.preventDefault();
+ const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) {
+ zoomIn(targetPanel);
+ } else if (delta > 0) {
+ zoomOut(targetPanel);
+ }
+ return;
+ }
+
+ // Regular wheel = scroll slices
+ const delta = e.deltaY > 0 ? 1 : -1;
+ const p = panels[targetPanel];
+ if (!p.slices.length) return;
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx);
+}, { passive: false });
+
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ if (document.getElementById('tourOverlay').classList.contains('show')) {
+ endTour();
+ } else if (document.getElementById('helpModal').classList.contains('show')) {
+ toggleHelp();
+ } else {
+ clearRect();
+ }
+ return;
+ }
+ if (!panels.length) return;
+
+ // +/- for zoom (affects hovered panel's orientation group)
+ if (e.key === '+' || e.key === '=') {
+ e.preventDefault();
+ zoomIn(hoveredPanel);
+ return;
+ }
+ if (e.key === '-' || e.key === '_') {
+ e.preventDefault();
+ zoomOut(hoveredPanel);
+ return;
+ }
+
+ // Arrow keys for slice navigation
+ let delta = 0;
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1;
+ if (delta === 0) return;
+ e.preventDefault();
+ const p = panels[0];
+ const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta));
+ if (newIdx !== p.currentSlice) goToSlice(0, newIdx);
+});
+
+// Cancel drawing if mouse leaves window
+document.addEventListener('mouseup', (e) => {
+ isDrawing = false;
+ if (isPanning) {
+ isPanning = false;
+ // Restore transition
+ document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = '');
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning'));
+ }
+});
+
+// Shift+click pan
+document.addEventListener('mousedown', (e) => {
+ if (e.button !== 0 || !e.shiftKey) return;
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ const state = zoomState[orientation];
+ if (state.level === 0) return; // no pan at 1x zoom
+
+ isPanning = true;
+ panOrientation = orientation;
+ panStartMouseX = e.clientX;
+ panStartMouseY = e.clientY;
+ panStartPanX = state.panX;
+ panStartPanY = state.panY;
+ // Disable transition during pan for smooth movement
+ panels.forEach((p, idx) => {
+ if (getPanelOrientation(idx) === orientation) {
+ const div = document.getElementById('panel-' + idx);
+ if (div) div.querySelector('.img-wrapper').style.transition = 'none';
+ }
+ });
+ document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning'));
+});
+
+document.addEventListener('mousemove', (e) => {
+ if (!isPanning || !panOrientation) return;
+
+ const state = zoomState[panOrientation];
+ const zoom = zoomLevels[state.level];
+ // With transform: scale(zoom) translate(panX, panY), translate values are scaled
+ // Divide by zoom for 1:1 screen-to-image movement
+ const dx = (e.clientX - panStartMouseX) / zoom;
+ const dy = (e.clientY - panStartMouseY) / zoom;
+ state.panX = panStartPanX + dx;
+ state.panY = panStartPanY + dy;
+ applyZoom(panOrientation);
+});
+
+// Double-click to reset zoom
+document.addEventListener('dblclick', (e) => {
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById('panel-' + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const orientation = getPanelOrientation(targetPanel);
+ resetZoom(orientation);
+});
+
+// Ctrl+click for Window/Level adjustment
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift
+ e.preventDefault();
+
+ // Find hovered panel
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const panel = panels[targetPanel];
+ if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return;
+
+ isAdjustingWL = true;
+ isDrawing = false; // Prevent rect drawing
+ document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none");
+ wlPanel = targetPanel;
+ wlStartX = e.clientX;
+ wlStartY = e.clientY;
+ wlStartWc = wlState[panel.seriesId].wc;
+ wlStartWw = wlState[panel.seriesId].ww;
+ document.body.style.cursor = "crosshair";
+
+ // Show hint
+ const hint = document.getElementById('wlHint');
+ hint.style.left = (e.clientX + 15) + 'px';
+ hint.style.top = (e.clientY - 10) + 'px';
+ hint.classList.add('show');
+});
+
+document.addEventListener("mousemove", (e) => {
+ if (!isAdjustingWL || wlPanel < 0) return;
+
+ const panel = panels[wlPanel];
+ if (!panel || !panel.seriesId) return;
+ const state = wlState[panel.seriesId];
+ if (!state) return;
+
+ // Horizontal = width, Vertical = center
+ const dx = e.clientX - wlStartX;
+ const dy = e.clientY - wlStartY;
+
+ state.ww = Math.max(1, wlStartWw + dx * 2);
+ state.wc = wlStartWc - dy * 2; // invert: drag up = brighter
+ state.adjusted = true;
+
+ // Update overlay C/W values in real-time
+ const div = document.getElementById("panel-" + wlPanel);
+ const wcEl = div.querySelector(".overlay-wc");
+ const wwEl = div.querySelector(".overlay-ww");
+ if (wcEl) wcEl.textContent = Math.round(state.wc);
+ if (wwEl) wwEl.textContent = Math.round(state.ww);
+
+ // Debounce image reload
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ wlDebounceTimer = setTimeout(() => {
+ const img = div.querySelector(".panel-content img");
+ img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId);
+ }, 150);
+});
+
+document.addEventListener("mouseup", (e) => {
+ if (isAdjustingWL) {
+ isAdjustingWL = false;
+ document.body.style.cursor = "";
+ document.getElementById('wlHint').classList.remove('show');
+ if (wlDebounceTimer) clearTimeout(wlDebounceTimer);
+ if (wlPanel >= 0) {
+ reloadPanelImages(wlPanel);
+ }
+ wlPanel = -1;
+ }
+});
+
+// Track right-click for double-click detection
+let lastRightClickTime = 0;
+let lastRightClickPanel = -1;
+
+// Double right-click to reset Window/Level
+document.addEventListener("mousedown", (e) => {
+ if (e.button !== 2) return;
+
+ let targetPanel = -1;
+ panels.forEach((p, idx) => {
+ const div = document.getElementById("panel-" + idx);
+ if (div) {
+ const rect = div.getBoundingClientRect();
+ if (e.clientX >= rect.left && e.clientX <= rect.right &&
+ e.clientY >= rect.top && e.clientY <= rect.bottom) {
+ targetPanel = idx;
+ }
+ }
+ });
+ if (targetPanel < 0) return;
+
+ const now = Date.now();
+ if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) {
+ // Double right-click detected - reset W/L
+ resetWL(targetPanel);
+ lastRightClickTime = 0;
+ lastRightClickPanel = -1;
+ e.preventDefault();
+ return;
+ }
+ lastRightClickTime = now;
+ lastRightClickPanel = targetPanel;
+});
+
+// Update crosshairs on window resize
+// Prevent context menu on panels for right-click W/L adjustment
+document.addEventListener("contextmenu", (e) => {
+ if (!e.target.closest("#panels")) return;
+ e.preventDefault();
+});
+
+window.addEventListener('resize', () => {
+ if (is3DMode) update3DCrosshairs();
+});
+
+// W/L Preset functions
+function applyWLPreset(el) {
+ const panelIdx = parseInt(el.dataset.panel);
+ const wcAttr = el.dataset.wc;
+ const wwAttr = el.dataset.ww;
+ const panel = panels[panelIdx];
+ if (!panel || !panel.seriesId) return;
+
+ // Update wlState - null means reset to original
+ if (wcAttr === 'null' || wwAttr === 'null') {
+ wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc;
+ wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw;
+ wlState[panel.seriesId].adjusted = false;
+ } else {
+ wlState[panel.seriesId].wc = parseInt(wcAttr);
+ wlState[panel.seriesId].ww = parseInt(wwAttr);
+ wlState[panel.seriesId].adjusted = true;
+ }
+
+ // Update active preset
+ const container = el.closest('.thumbnails');
+ container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active'));
+ el.classList.add('active');
+
+ // Reload image
+ reloadPanelImages(panelIdx);
+}
+
+function setupScrubber(panelIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const track = scrubber.querySelector('.scrubber-track');
+ let isDragging = false;
+
+ const updateFromPosition = (e) => {
+ const rect = track.getBoundingClientRect();
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
+ const pct = x / rect.width;
+ const sliceCount = panels[panelIdx].slices.length;
+ const sliceIdx = Math.round(pct * (sliceCount - 1));
+ goToSlice(panelIdx, sliceIdx);
+ };
+
+ track.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ updateFromPosition(e);
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (isDragging) updateFromPosition(e);
+ });
+
+ document.addEventListener('mouseup', () => {
+ isDragging = false;
+ });
+}
+
+function updateScrubber(panelIdx, sliceIdx) {
+ const panel = document.getElementById('panel-' + panelIdx);
+ if (!panel) return;
+ const scrubber = panel.querySelector('.slice-scrubber');
+ if (!scrubber) return;
+
+ const sliceCount = panels[panelIdx].slices.length;
+ const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0;
+
+ const fill = scrubber.querySelector('.scrubber-fill');
+ const handle = scrubber.querySelector('.scrubber-handle');
+ const current = scrubber.querySelector('.scrubber-current');
+
+ if (fill) fill.style.width = pct + '%';
+ if (handle) handle.style.left = pct + '%';
+ if (current) current.textContent = sliceIdx + 1;
+}
+
+init();
diff --git a/status.sh b/status.sh
new file mode 100644
index 0000000..81d2086
--- /dev/null
+++ b/status.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+cd /tank/inou
+
+echo "=== Inou Status ==="
+echo ""
+
+# Services
+echo "Services:"
+if pgrep -f "bin/api$" > /dev/null; then
+ echo " API: running (PID $(pgrep -f 'bin/api$'))"
+else
+ echo " API: stopped"
+fi
+
+if pgrep -f "bin/viewer$" > /dev/null; then
+ echo " Viewer: running (PID $(pgrep -f 'bin/viewer$'))"
+else
+ echo " Viewer: stopped"
+fi
+
+if pgrep -f "bin/portal$" > /dev/null; then
+ echo " Portal: running (PID $(pgrep -f 'bin/portal$'))"
+else
+ echo " Portal: stopped"
+fi
+
+echo ""
+echo "Endpoints:"
+echo " Portal: https://inou.com"
+echo " Viewer: https://inou.com:8767"
+echo " API: https://inou.com/api/* (internal :8082)"
+
+echo ""
+echo "FIPS 140-3 Build Status:"
+
+if [[ -x "bin/fips-check" ]]; then
+ bin/fips-check bin/api bin/portal bin/viewer bin/import-genome bin/lab-scrape bin/lab-import 2>/dev/null | sed 's/^/ /'
+else
+ echo " (fips-check not found - run make deploy)"
+fi
diff --git a/stop.sh b/stop.sh
new file mode 100644
index 0000000..effd205
--- /dev/null
+++ b/stop.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+# Stop Inou services
+echo "=== Inou Stop ==="
+
+pkill -f "bin/api$" && echo "API: stopped" || echo "API: not running"
+pkill -f "bin/viewer$" && echo "Viewer: stopped" || echo "Viewer: not running"
+pkill -f "bin/portal$" && echo "Portal: stopped" || echo "Portal: not running"
diff --git a/templates/add_dossier.tmpl b/templates/add_dossier.tmpl
new file mode 100644
index 0000000..10828bf
--- /dev/null
+++ b/templates/add_dossier.tmpl
@@ -0,0 +1,109 @@
+{{define "add_dossier"}}
+
+
+
+
+
{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}
+
{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+
+ {{template "footer"}}
+
+
+{{end}}
\ No newline at end of file
diff --git a/templates/api.tmpl b/templates/api.tmpl
new file mode 100644
index 0000000..d020f11
--- /dev/null
+++ b/templates/api.tmpl
@@ -0,0 +1,143 @@
+{{define "api"}}
+
+
+
+
API
+
Access your health dossier data programmatically — or let AI do it for you.
+
+ {{if .Dossier}}
+
+
{{.T.api_token}}
+ {{if .APIToken}}
+
{{.T.api_token_use}}
+
+
+
+
+
{{.T.api_token_warning}}
+
+ {{else}}
+
{{.T.api_token_none}}
+
+ {{end}}
+
+
+ {{end}}
+
+
{{.T.api_authentication}}
+
{{.T.api_auth_instructions}}
+
Authorization: Bearer YOUR_API_TOKEN
+
+
Endpoints
+
+
Dossiers
+
+
+
GET /api/v1/dossiers
+
List all dossiers accessible to this account (your own + any shared with you).
+
+
+
Imaging
+
+
+
GET /api/v1/dossiers/{id}/entries?category=imaging
+
List all imaging studies in a dossier. Returns study ID, date, description, and series count.
+
+
+
+
GET /api/v1/entries/{study_id}/children
+
List series in a study. Optional: ?filter=SAG or ?filter=T1 to filter by description.
+
+
+
+
GET /api/v1/entries/{series_id}/children
+
List slices with position data (mm coordinates, orientation, pixel spacing).
+
+
+
+
GET /api/v1/entries/{slice_id}?detail=full
+
Get slice image as PNG. Optional: &ww=WIDTH&wc=CENTER for windowing.
+
+
+
Genome
+
+
+
GET /api/v1/dossiers/{id}/entries?category=genome
+
List genome variant categories: medication, cardiovascular, metabolism, fertility, traits, longevity.
+
+
+
+
GET /api/v1/dossiers/{id}/genome?search=MTHFR
+
Query genome variants. Optional filters: &category=medication, &rsids=rs1234,rs5678, &min_magnitude=2
+
+
+
Labs
+
+
+
GET /api/v1/dossiers/{id}/labs/tests
+
List all available lab test names for a dossier.
+
+
+
+
GET /api/v1/dossiers/{id}/labs/results?names=TSH,T4
+
Get lab results. Required: &names= (comma-separated). Optional: &from=2024-01-01, &to=2024-12-31, &latest=true
+
+
+
+ Text Format: Add &format=text to any endpoint for AI-friendly plain text output instead of JSON.
+
+
+
Example
+
# List your dossiers
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ https://inou.com/api/v1/dossiers
+
+# List imaging studies
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ https://inou.com/api/v1/dossiers/DOSSIER_ID/entries?category=imaging
+
+# Query genome variants
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ https://inou.com/api/v1/dossiers/DOSSIER_ID/genome?search=MTHFR
+
+
+{{end}}
diff --git a/templates/audit.tmpl b/templates/audit.tmpl
new file mode 100644
index 0000000..df09388
--- /dev/null
+++ b/templates/audit.tmpl
@@ -0,0 +1,50 @@
+{{define "audit"}}
+
+
+
+
+
+
+
+ {{range .AuditList}}
+
+
+ {{.ActorName}}
+ {{.Action}}
+
+
+ {{.Details}}
+
+
+
+ {{else}}
+
+ No activity recorded yet
+
+ {{end}}
+
+
+
+
+
+{{end}}
diff --git a/templates/base.tmpl b/templates/base.tmpl
new file mode 100644
index 0000000..ccba68f
--- /dev/null
+++ b/templates/base.tmpl
@@ -0,0 +1,122 @@
+
+
+
+
+
+ inou{{if .Title}} - {{.Title}}{{end}}
+
+
+
+
+
+
+ {{if .Embed}}{{end}}
+
+
+ {{if not .Embed}}
+
+ {{end}}
+
+ {{if eq .Page "landing"}}{{template "landing" .}}
+ {{else if eq .Page "landing_nl"}}{{template "landing_nl" .}}
+ {{else if eq .Page "landing_ru"}}{{template "landing_ru" .}}
+ {{else if eq .Page "landing_de"}}{{template "landing_de" .}}
+ {{else if eq .Page "landing_fr"}}{{template "landing_fr" .}}
+ {{else if eq .Page "landing_es"}}{{template "landing_es" .}}
+ {{else if eq .Page "landing_pt"}}{{template "landing_pt" .}}
+ {{else if eq .Page "landing_it"}}{{template "landing_it" .}}
+ {{else if eq .Page "landing_sv"}}{{template "landing_sv" .}}
+ {{else if eq .Page "landing_no"}}{{template "landing_no" .}}
+ {{else if eq .Page "landing_da"}}{{template "landing_da" .}}
+ {{else if eq .Page "landing_fi"}}{{template "landing_fi" .}}
+ {{else if eq .Page "landing_ja"}}{{template "landing_ja" .}}
+ {{else if eq .Page "landing_ko"}}{{template "landing_ko" .}}
+ {{else if eq .Page "landing_zh"}}{{template "landing_zh" .}}
+ {{else if eq .Page "verify"}}{{template "verify" .}}
+ {{else if eq .Page "onboard"}}{{template "onboard" .}}
+ {{else if eq .Page "minor_error"}}{{template "minor_error" .}}
+ {{else if eq .Page "dashboard"}}{{template "dashboard" .}}
+ {{else if eq .Page "dossier"}}{{template "dossier" .}}
+ {{else if eq .Page "add_dossier"}}{{template "add_dossier" .}}
+ {{else if eq .Page "share"}}{{template "share" .}}
+ {{else if eq .Page "upload"}}{{template "upload" .}}
+ {{else if eq .Page "audit"}}{{template "audit" .}}
+ {{else if eq .Page "connect"}}{{template "connect" .}}
+ {{else if eq .Page "connect_nl"}}{{template "connect_nl" .}}
+ {{else if eq .Page "connect_ru"}}{{template "connect_ru" .}}
+ {{else if eq .Page "invite"}}{{template "invite" .}}
+ {{else if eq .Page "login"}}{{template "login" .}}
+ {{else if eq .Page "privacy"}}{{template "privacy" .}}
+ {{else if eq .Page "security"}}{{template "security" .}}
+ {{else if eq .Page "dpa"}}{{template "dpa" .}}
+ {{else if eq .Page "styleguide"}}{{template "styleguide" .}}
+ {{else if eq .Page "pricing"}}{{template "pricing" .}}
+ {{else if eq .Page "faq"}}{{template "faq" .}}
+ {{else if eq .Page "prompts"}}{{template "prompts" .}}
+ {{else if eq .Page "permissions"}}{{template "permissions" .}}
+ {{else if eq .Page "edit_access"}}{{template "edit_access" .}}
+ {{end}}
+
+
+
+
diff --git a/templates/connect.tmpl b/templates/connect.tmpl
new file mode 100644
index 0000000..3d2e6fb
--- /dev/null
+++ b/templates/connect.tmpl
@@ -0,0 +1,286 @@
+{{define "connect"}}
+
+
+
+
+ {{if not (and .Dossier .Dossier.DossierID)}}
+
+
Note: Sign in to see personalized setup instructions with your account token pre-filled.
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.
+
+
+
+
+
+
Download inou.mcpb and install:
+
+ - Mac: Double-click the file
+ - Windows: In Claude Desktop, go to File → Import Extension and select the downloaded file
+
+
When prompted, enter your API token:
+ {{if and .Dossier .Dossier.DossierID}}
+ {{if .APIToken}}
+
+
{{.APIToken}}
+
+
+ {{else}}
+
+ {{end}}
+ {{else}}
+
+ {{end}}
+
Note: You'll see a permissions warning — this is normal for any extension not yet in Claude's official directory. inou only makes HTTPS calls to inou.com; it does not access local files.
+
+
+
+
+
Go to Settings → Extensions and enable the Inou extension if it's not already on.
+
When first using the extension, Claude will ask permission for each tool. Select "Allow for this chat" and check "Don't ask again for inou-health" to grant all permissions at once — otherwise you'll be prompted 11 times.
+
+
+
+
+
Open Claude Desktop and paste:
+
+
List my available dossiers using the inou bridge.
+
+
+
You should see your dossier(s). You're all set!
+
+
+
+ Manual installation (advanced)
+
+
+
+
Download the bridge for your system:
+
+
Save to ~/bin/inou_bridge (Mac) or %USERPROFILE%\inou\inou_bridge.exe (Windows). Make executable on Mac: chmod +x ~/bin/inou_bridge
+
+
+
+
Edit Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) and add to mcpServers:
+
+
"inou": {
+ "command": "BRIDGE_PATH",
+ "args": ["--server=https://inou.com", "--account={{if .APIToken}}{{.APIToken}}{{else}}YOUR_API_TOKEN{{end}}"]
+}
+
+
+
Replace BRIDGE_PATH with the actual path. Restart Claude Desktop.
+
+
+
+
+
+
+
+
Grok can access your health data through our API using HTTP requests. No installation needed.
+
+ {{if and .Dossier .Dossier.DossierID}}
+ {{if .TempToken}}
+
+
+
+
Access my health data using the Inou API.
+
+Fetch https://inou.com/api/v1/dossiers?token={{.TempToken}}
+
+Show me the list of dossiers with their details and wait for my instructions.
+
+API docs: https://inou.com/api/docs
+
+IMPORTANT: This is real medical data. NEVER hallucinate. Only describe what you see.
+
+
+
Token expires at {{.TempTokenExpires}}. Refresh page for a new token.
+
+ {{else}}
+
+
+
You need an API token to connect Grok to your data.
+
+
+ {{end}}
+ {{else}}
+
+
Sign in to generate your API token and get personalized setup instructions.
+
+ {{end}}
+
+
+
+
Once connected, ask Grok to:
+
+ - List all your imaging studies, genome data, and lab results
+ - Show series within a specific study
+ - Fetch and analyze individual slices
+ - Compare images across different sequences (T1, T2, FLAIR)
+ - Navigate to specific anatomical regions
+ - Query genome variants by gene, category, or rsid
+ - Review medication responses and health risks
+ - Track lab values over time
+
+
+
+
See the full API documentation for all available endpoints.
+
+
+
+
+
Not recommended for medical imaging due to elevated hallucination risk in our testing.
+
+
+
+
Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.
+
+
+
+
+
Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.
+
+
+
+
+
+
Other AI assistants can access your data through our web API, though capabilities vary.
+
+
+
+
Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:
+
+ - Copy API responses manually and paste them into Gemini
+ - Use Google AI Studio with function calling
+ - Consider using Claude Desktop or Grok instead
+
+
+
+
+
+
Our API is simple REST + JSON. See the API documentation for endpoints and authentication.
+
+
+
+
+ {{template "footer"}}
+
+
+
+
+{{end}}
diff --git a/templates/connect_nl.tmpl b/templates/connect_nl.tmpl
new file mode 100644
index 0000000..c7998fc
--- /dev/null
+++ b/templates/connect_nl.tmpl
@@ -0,0 +1,243 @@
+{{define "connect_nl"}}
+
+
+
+
+ {{if not (and .Dossier .Dossier.DossierID)}}
+
+
Let op: Log in om gepersonaliseerde instructies te zien met je account-token al ingevuld.
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
Claude Desktop met MCP biedt de beste ervaring — native tool-toegang, geen URL-fetching, volledige API-mogelijkheden.
+
+
+
+
+
+
Download inou.mcpb en installeer:
+
+ - Mac: Dubbelklik op het bestand
+ - Windows: Ga in Claude Desktop naar File → Import Extension en selecteer het gedownloade bestand
+
+
Voer je account-token in wanneer daarom gevraagd wordt:
+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}
+
+
+
Let op: Je ziet een machtigingswaarschuwing — dit is normaal voor extensies die nog niet in Claude's officiële directory staan. inou maakt alleen HTTPS-verbindingen met inou.com; het benadert geen lokale bestanden.
+
+
+
+
+
Ga naar Settings → Extensions en schakel de inou-extensie in als dat nog niet gedaan is.
+
Bij het eerste gebruik vraagt Claude toestemming voor elke tool. Selecteer "Allow for this chat" en vink "Don't ask again for inou-health" aan om alle machtigingen in één keer toe te staan — anders word je 11 keer gevraagd.
+
+
+
+
+
Open Claude Desktop en plak:
+
+
Toon mijn beschikbare dossiers via de inou-bridge.
+
+
+
Je zou je dossier(s) moeten zien. Klaar!
+
+
+
+ Handmatige installatie (geavanceerd)
+
+
+
+
Download de bridge voor jouw systeem:
+
+
Sla op naar ~/bin/inou_bridge (Mac) of %USERPROFILE%\inou\inou_bridge.exe (Windows). Maak uitvoerbaar op Mac: chmod +x ~/bin/inou_bridge
+
+
+
+
Bewerk Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) en voeg toe aan mcpServers:
+
+
"inou": {
+ "command": "BRIDGE_PATH",
+ "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}"]
+}
+
+
+
Vervang BRIDGE_PATH door het daadwerkelijke pad. Herstart Claude Desktop.
+
+
+
+
+
+
+
+
Grok kan via het web je gezondheidsgegevens benaderen via onze API. Geen installatie nodig.
+
+
+
Snel starten
+
Kopieer dit en plak het in Grok:
+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+
+
+
+
+
+
+
Eenmaal verbonden kun je Grok vragen om:
+
+ - Al je beeldvormingsstudies, genoomgegevens en labresultaten te tonen
+ - Series binnen een specifieke studie te laten zien
+ - Individuele slices op te halen en te analyseren
+ - Beelden te vergelijken tussen verschillende sequenties (T1, T2, FLAIR)
+ - Naar specifieke anatomische regio's te navigeren
+ - Genoomvarianten op te zoeken op gen, categorie of rsid
+ - Medicatiereacties en gezondheidsrisico's te bekijken
+ - Labwaarden in de tijd te volgen
+
+
+
+
Zie de volledige API-documentatie voor alle beschikbare endpoints.
+
+
+
+
+
Niet aanbevolen voor medische beeldvorming vanwege verhoogd risico op hallucinaties in onze tests.
+
+
+
+
Medische beeldvorming vereist absolute nauwkeurigheid. In onze tests verzon ChatGPT informatie, zelfs wanneer correcte gegevens duidelijk werden verstrekt. We kunnen het niet aanbevelen voor het analyseren van gezondheidsgegevens waar fouten echte gevolgen hebben.
+
+
+
+
+
Gebruik Claude Desktop voor de beste ervaring met native tool-toegang, of Grok voor webtoegang zonder installatie.
+
+
+
+
+
+
Andere AI-assistenten kunnen je gegevens benaderen via onze web-API, hoewel mogelijkheden variëren.
+
+
+
+
Gemini's webbrowsing is momenteel beperkt en kan mogelijk geen inou.com-URL's direct ophalen. Workarounds:
+
+ - Kopieer API-responses handmatig en plak ze in Gemini
+ - Gebruik Google AI Studio met function calling
+ - Overweeg Claude Desktop of Grok in plaats daarvan
+
+
+
+
+
+
Onze API is eenvoudige REST + JSON. Zie de API-documentatie voor endpoints en authenticatie.
+
+
+
+
+ {{template "footer"}}
+
+
+
+
+{{end}}
diff --git a/templates/connect_ru.tmpl b/templates/connect_ru.tmpl
new file mode 100644
index 0000000..4aa72be
--- /dev/null
+++ b/templates/connect_ru.tmpl
@@ -0,0 +1,243 @@
+{{define "connect_ru"}}
+
+
+
+
+ {{if not (and .Dossier .Dossier.DossierID)}}
+
+
Примечание: Войдите, чтобы увидеть персонализированные инструкции с вашим токеном учётной записи.
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
Claude Desktop с MCP обеспечивает лучший опыт — нативный доступ к инструментам, без загрузки URL, полные возможности API.
+
+
+
+
+
+
Скачайте inou.mcpb и установите:
+
+ - Mac: Дважды щёлкните по файлу
+ - Windows: В Claude Desktop перейдите в File → Import Extension и выберите скачанный файл
+
+
При запросе введите токен вашей учётной записи:
+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}
+
+
+
Примечание: Вы увидите предупреждение о разрешениях — это нормально для расширений, ещё не включённых в официальный каталог Claude. inou делает только HTTPS-запросы к inou.com; локальные файлы не затрагиваются.
+
+
+
+
+
Перейдите в Settings → Extensions и включите расширение inou, если оно ещё не включено.
+
При первом использовании Claude запросит разрешение для каждого инструмента. Выберите "Allow for this chat" и отметьте "Don't ask again for inou-health", чтобы дать все разрешения сразу — иначе вас спросят 11 раз.
+
+
+
+
+
Откройте Claude Desktop и вставьте:
+
+
Покажи мои доступные досье через мост inou.
+
+
+
Вы должны увидеть ваше(и) досье. Готово!
+
+
+
+ Ручная установка (продвинутый)
+
+
+
+
Скачайте мост для вашей системы:
+
+
Сохраните в ~/bin/inou_bridge (Mac) или %USERPROFILE%\inou\inou_bridge.exe (Windows). Сделайте исполняемым на Mac: chmod +x ~/bin/inou_bridge
+
+
+
+
Отредактируйте конфигурацию Claude (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) и добавьте в mcpServers:
+
+
"inou": {
+ "command": "BRIDGE_PATH",
+ "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}"]
+}
+
+
+
Замените BRIDGE_PATH на фактический путь. Перезапустите Claude Desktop.
+
+
+
+
+
+
+
+
Grok может получить доступ к вашим медицинским данным напрямую через наш API. Установка не требуется.
+
+
+
Быстрый старт
+
Скопируйте это и вставьте в Grok:
+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+
+
+
+
+
+
+
После подключения попросите Grok:
+
+ - Показать все ваши исследования визуализации, геномные данные и результаты анализов
+ - Показать серии в конкретном исследовании
+ - Загрузить и проанализировать отдельные срезы
+ - Сравнить изображения между разными последовательностями (T1, T2, FLAIR)
+ - Перейти к определённым анатомическим областям
+ - Найти геномные варианты по гену, категории или rsid
+ - Просмотреть реакции на лекарства и риски для здоровья
+ - Отслеживать лабораторные показатели во времени
+
+
+
+
См. полную документацию API для всех доступных эндпоинтов.
+
+
+
+
+
Не рекомендуется для медицинской визуализации из-за повышенного риска галлюцинаций в наших тестах.
+
+
+
+
Медицинская визуализация требует абсолютной точности. В наших тестах ChatGPT выдумывал информацию, даже когда правильные данные были чётко предоставлены. Мы не можем рекомендовать его для анализа медицинских данных, где ошибки имеют реальные последствия.
+
+
+
+
+
Используйте Claude Desktop для лучшего опыта с нативным доступом к инструментам, или Grok для веб-доступа без установки.
+
+
+
+
+
+
Другие ИИ-ассистенты могут получить доступ к вашим данным через наш веб-API, хотя возможности различаются.
+
+
+
+
Веб-браузинг Gemini в настоящее время ограничен и может не загружать URL-адреса inou.com напрямую. Обходные пути:
+
+ - Скопируйте ответы API вручную и вставьте их в Gemini
+ - Используйте Google AI Studio с вызовом функций
+ - Рассмотрите вместо этого Claude Desktop или Grok
+
+
+
+
+
+
Наш API — простой REST + JSON. См. документацию API для эндпоинтов и аутентификации.
+
+
+
+
+ {{template "footer"}}
+
+
+
+
+{{end}}
diff --git a/templates/dashboard.tmpl b/templates/dashboard.tmpl
new file mode 100644
index 0000000..fa61703
--- /dev/null
+++ b/templates/dashboard.tmpl
@@ -0,0 +1,67 @@
+{{define "dashboard"}}
+
+
{{.T.dossiers}}
+
{{.T.dossiers_intro}}
+
+
+
+ {{template "footer"}}
+
+{{end}}
diff --git a/templates/dossier.tmpl b/templates/dossier.tmpl
new file mode 100644
index 0000000..0fb6ee5
--- /dev/null
+++ b/templates/dossier.tmpl
@@ -0,0 +1,752 @@
+{{define "dossier"}}
+
+
+
+
+ {{if .Error}}
{{.Error}}
{{end}}
+ {{if .Success}}
{{.Success}}
{{end}}
+
+
+
+
+
+ {{if .Studies}}
+
+ {{range $i, $s := .Studies}}
+ {{if eq $s.SeriesCount 1}}
+
+
+ {{$s.Description}}
+
+
+
+ {{else}}
+
+
+ +
+ {{$s.Description}}
+
+
+
+
+ {{range $s.Series}}{{if gt .SliceCount 0}}
+
+
{{if .Description}}{{.Description}}{{else}}{{.Modality}}{{end}}
+
+
→
+
+ {{end}}{{end}}
+
+ {{end}}
+ {{end}}
+ {{if gt .StudyCount 5}}
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Labs}}
+
+ {{range .Labs}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Documents}}
+
+ {{range .Documents}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Type}}{{.Type}}{{end}}
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Procedures}}
+
+ {{range .Procedures}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Assessments}}
+
+ {{range .Assessments}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
⚠️ Before you continue
+
Here you can browse all your raw genetic variants. However, the real value comes from using Claude and other LLMs with your health dossier — they can interpret these variants and correlate them with your labs, imaging, and medical history.
+
Keep in mind:
+
+ - Many associations are based on early or limited research
+ - A "risk variant" means slightly higher odds — not a diagnosis
+ - Consumer tests (23andMe, AncestryDNA) can have false positives
+
+
These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{if .Medications}}
+
+ {{range .Medications}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Symptoms}}
+
+ {{range .Symptoms}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Hospitalizations}}
+
+ {{range .Hospitalizations}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ {{if .Therapies}}
+
+ {{range .Therapies}}
+
+
+ {{.Value}}
+ {{if .Summary}}{{.Summary}}{{end}}
+
+
+ {{if .Date}}{{.Date}}{{end}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{range .AccessList}}
+
+
+ {{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}}
+ {{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}}
+
+ {{if and $.CanManageAccess (not .IsSelf)}}
+
+ {{end}}
+
+ {{end}}
+ {{if not .AccessList}}
+
+ {{.T.no_access_yet}}
+
+ {{end}}
+
+
+
+
+
+
+ {{template "footer"}}
+
+
+
+{{end}}
diff --git a/templates/dpa.tmpl b/templates/dpa.tmpl
new file mode 100644
index 0000000..aa45c94
--- /dev/null
+++ b/templates/dpa.tmpl
@@ -0,0 +1,248 @@
+{{define "dpa"}}
+
+
+
+
+
+
Data Processing Agreement
+
This agreement describes how inou processes your health data. It applies to all users and any third-party services that access your data through our platform.
+
+
+
+
Definitions
+
+
Data Controller.
+
You. You decide what data to upload, who can access it, and when to delete it.
+
+
Data Processor.
+
inou. We store, encrypt, and transmit your data according to your instructions.
+
+
Sub-processors.
+
Third-party services you explicitly connect to your account, such as AI assistants. We do not use sub-processors for storage or core functionality.
+
+
+
+
Data we process
+
+
Health data.
+
Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.
+
+
Account data.
+
Name, email address, date of birth, and sex. Used for account management and medical context.
+
+
Technical data.
+
IP addresses and session identifiers. Used exclusively for security and access control.
+
+
+
+
How we process it
+
+
Storage.
+
All health data is encrypted using FIPS 140-3 validated cryptography before storage. Data resides on dedicated infrastructure in the United States that we own and operate.
+
+
Transmission.
+
All data in transit is protected by TLS 1.3 encryption. When you connect third-party services, data travels through an encrypted bridge directly to your session.
+
+
Access.
+
Only you and accounts you explicitly authorize can access your data. Staff access requires your explicit request, is restricted to senior personnel, and is logged.
+
+
+
+
Processing restrictions
+
+
We process your data solely to provide the service. Specifically, we do not:
+
+ - Use your data for AI model training
+ - Sell, rent, or share your data with third parties
+ - Analyze your data for advertising or profiling
+ - Access your data without your explicit request
+ - Retain your data after account deletion
+
+
+
+
+
Third-party connections
+
+
When you connect an AI assistant or other service to inou:
+
+ - You explicitly authorize each connection
+ - Data is transmitted only for your active session
+ - We do not store copies of transmitted data
+ - You can revoke access at any time
+ - Each third party operates under their own privacy policy
+
+
We recommend reviewing the privacy policy of any service you connect.
+
+
+
+
Security measures
+
+
Encryption.
+
FIPS 140-3 validated encryption at rest. TLS 1.3 encryption in transit. Application-layer encryption before database storage.
+
+
Infrastructure.
+
Dedicated hardware. No shared cloud environments. Redundant storage with RAID-Z2. Uninterruptible power with generator backup.
+
+
Access control.
+
Role-based access control. Mandatory authentication. All access logged and auditable.
+
+
Monitoring.
+
Continuous automated monitoring. Intrusion detection. Regular security assessments.
+
+
+
+
Data retention
+
+
We retain your data for as long as your account is active. When you delete your account:
+
+ - All personal data is permanently destroyed
+ - All health data is permanently destroyed
+ - Deletion is immediate and irreversible
+ - Backups are overwritten within 30 days
+
+
We do not offer recovery of deleted data.
+
+
+
+
Your rights
+
+
Access.
+
See and export everything we store — data you've entered, account details, access logs, and audit history.
+
+
Rectification.
+
Correct any inaccurate data directly or by request.
+
+
Erasure.
+
Delete your account and all associated data instantly.
+
+
Portability.
+
Download data you've entered in standard formats. Your uploaded files are already yours.
+
+
Objection.
+
Revoke any permission at any time. We comply immediately.
+
+
+
+
Compliance
+
+
This agreement is designed to comply with:
+
+ - GDPR (European Union General Data Protection Regulation)
+ - FADP (Swiss Federal Act on Data Protection)
+ - HIPAA (US Health Insurance Portability and Accountability Act)
+
+
We apply the highest standard regardless of your jurisdiction.
+
+
+
+
Contact
+
Questions about data processing: privacy@inou.com
+
This agreement was last updated on January 21, 2026.
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/edit_access.tmpl b/templates/edit_access.tmpl
new file mode 100644
index 0000000..7c66053
--- /dev/null
+++ b/templates/edit_access.tmpl
@@ -0,0 +1,89 @@
+{{define "edit_access"}}
+
+
+
+
+
+
Edit access
+
{{.GranteeName}}'s access to {{.TargetDossier.Name}}
+
+
{{.T.back}}
+
+
+ {{if .Error}}
{{.Error}}
{{end}}
+ {{if .Success}}
{{.Success}}
{{end}}
+
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/templates/faq.tmpl b/templates/faq.tmpl
new file mode 100644
index 0000000..4678daa
--- /dev/null
+++ b/templates/faq.tmpl
@@ -0,0 +1,1097 @@
+{{define "faq"}}
+
+
+
+
+
+
+
+
+
+
+
+ +
+ What are the different pricing tiers?
+
+
+
+
+
Monitor (Free)
+
+ - Track vitals, period/fertility, exercise, symptoms, and food
+ - Text and voice entry
+ - Up to 4 dossiers (family members)
+ - 100MB storage
+ - MCP integration with limited AI insights
+ - Perfect for basic health tracking
+
+
Optimize ($12/month or $120/year)
+
+ - Everything in Monitor, plus:
+ - Photo uploads with OCR
+ - Supplements & medications tracking
+ - Family history
+ - Lab results tracking
+ - Consumer genome analysis (~160 curated variants covering pharmacogenomics, disease risks, methylation, athletic performance, nutrition, personality traits, and physical traits)
+ - Full AI insights, trend analysis, predictions, and health correlations
+ - 1GB storage
+ - Up to 4 dossiers
+
+
Research ($35/month or $350/year)
+
+ - Everything in Optimize, plus:
+ - Medical imaging (MRI, CT, X-ray scans)
+ - Complete genome analysis (all 5,000+ variants from SNPedia)
+ - Clinical genome sequencing support
+ - Browse and search any genetic variant
+ - 100GB storage (imaging files are large)
+ - Up to 4 dossiers
+
+
+
+
+
+
+ +
+ How much does the annual plan save?
+
+
+
+
+
Annual plans are priced at 10 months - you get 2 months free:
+
+ - Optimize: $120/year instead of $144 (save $24)
+ - Research: $350/year instead of $420 (save $70)
+
+
+
+
+
+
+ +
+ What does "free until July 1, 2026" mean?
+
+
+
+
+
inou is in active development. If you sign up now:
+
+ - No charges until July 1, 2026 - use any paid tier completely free
+ - No auto-renewal on July 1, 2026 - we'll ask if you want to continue
+ - No credit card required during early access - just sign up and start using it
+ - Choose to continue with a paid plan or stay on the free Monitor tier after July 1st
+
+
This gives you 6+ months to try inou with full access to Optimize or Research features before deciding if you want to pay.
+
+
+
+
+
+ +
+ Can I upgrade or downgrade my plan?
+
+
+
+
+
Yes, you can change your plan at any time:
+
+ - Upgrade: Takes effect immediately, you get access to new features right away
+ - Downgrade: Takes effect at the end of your current billing period
+ - Data preservation: Your data is never deleted when you downgrade - features just become read-only until you upgrade again
+
+
For example, if you downgrade from Optimize to Monitor, your lab results and genome data remain stored, but you'll lose AI analysis features until you upgrade again.
+
+
+
+
+
+ +
+ What happens if I exceed my storage limit?
+
+
+
+
+
+ - Monitor (100MB): You'll receive a notification when you reach 80% and 95% of your limit. At 100%, you can't add new data until you upgrade or delete old entries.
+ - Optimize (1GB): Same notification system. 1GB covers approximately 1,000 lab PDFs or 50 consumer genome files.
+ - Research (100GB): Designed for medical imaging. 100GB covers roughly 200-400 MRI/CT studies depending on series count.
+
+
We don't charge overage fees. If you need more storage, you'll need to upgrade to the next tier or manage your existing data.
+
+
+
+
+
+ +
+ Can I use inou for my whole family?
+
+
+
+
+
Yes! All tiers include up to 4 dossiers. A "dossier" is an individual health profile. This means you can:
+
+ - Track your own health plus 3 family members (spouse, children, parents)
+ - Manage your child's medical records
+ - Help an elderly parent track their medications and appointments
+ - Keep separate profiles for complex multi-person health situations
+
+
Each dossier has its own data, permissions, and privacy settings. Family members can have their own login access or you can manage everything from your account.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ What data can I track in inou?
+
+
+
+
+
All tiers:
+
+ - Vitals: blood pressure, heart rate, weight, temperature, oxygen saturation
+ - Period/fertility: cycle dates, flow, symptoms, basal body temperature, cervical mucus
+ - Exercise: type, duration, intensity, routes, performance metrics
+ - Symptoms: headaches, pain, fatigue, digestive issues, mood, sleep quality
+ - Food: meal logging via text, voice, or photo
+
+
Optimize & Research add:
+
+ - Supplements & medications: dosage, timing, refills, interactions
+ - Family history: genetic conditions, disease patterns
+ - Lab results: blood tests, panels, biomarkers with trend tracking
+ - Consumer genome: 23andMe, AncestryDNA, or similar test results
+
+
Research tier adds:
+
+ - Medical imaging: MRI, CT, X-ray scans (DICOM format)
+ - Clinical genome sequencing: whole exome, whole genome, gene panels
+
+
+
+
+
+
+ +
+ How does medical imaging work with AI?
+
+
+
+
+
This is what makes inou different.
+
A single MRI can contain thousands of images across multiple sequences (T1, T2, FLAIR, etc.). Uploading all of them to an AI chat is impractical - you'd hit context limits and waste time selecting slices manually.
+
inou solves this:
+
+ - Upload your entire scan once (we handle DICOM natively)
+ - Your AI sees the study metadata - what sequences exist, how many slices, anatomical positions
+ - When analyzing, the AI requests exactly the slices it needs
+ - It can pull specific images on demand: "show me axial T2 slice 45" or "compare sagittal FLAIR at L4-L5"
+
+
The AI intelligently navigates your imaging library without you having to manually find and upload individual images. Ask about a herniated disc, and it fetches the relevant spine sequences. Ask about a brain lesion, and it pulls the right FLAIR slices.
+
Example: "Look at my May 2024 lumbar MRI and tell me if there's any change compared to my January 2023 scan" - the AI fetches and compares the relevant slices from both studies automatically.
+
+
+
+
+
+ +
+ How does genome analysis work?
+
+
+
+
+
For Optimize ($12/mo):
+
Upload your raw data file from 23andMe, AncestryDNA, or similar consumer tests. inou analyzes ~160 carefully curated, high-confidence genetic variants:
+
+ - Pharmacogenomics (40 variants): Which medications work best for your genetics (statins, blood thinners, antidepressants, pain meds)
+ - Disease risk (30 variants): BRCA1/2, APOE (Alzheimer's), Lynch syndrome, familial hypercholesterolemia, clotting disorders
+ - Methylation & detox (10 variants): MTHFR, CBS, MTR - affects folate metabolism, B12 needs, homocysteine
+ - Athletic performance (20 variants): Muscle fiber type, VO2max potential, injury risk, recovery speed
+ - Nutrition (20 variants): Lactose intolerance, caffeine metabolism, alcohol flush, vitamin needs
+ - Personality & traits (20 variants): COMT (stress response), sleep chronotype, pain sensitivity
+ - Physical traits (20 variants): Eye color, hair traits, earwax type, bitter taste
+
+
For Research ($35/mo):
+
Everything above PLUS access to all 5,000+ variants analyzed by SNPedia, including:
+
+ - Rare disease variants
+ - Uncertain/early research findings
+ - Polygenic risk scores for 50+ conditions
+ - Carrier status for 200+ recessive conditions
+ - Search any rsID or gene
+ - Clinical genome sequencing data (WES/WGS)
+
+
Privacy control: You choose whether to show protective variants (good news), risk variants (bad news), or both. Some people want comprehensive information; others prefer not to see risks they can't control.
+
+
+
+
+
+ +
+ What is MCP integration?
+
+
+
+
+
inou connects AI assistants like Claude and Grok directly to your health data. Instead of manually copying and pasting information, your AI can:
+
+ - Read your complete medical history
+ - Analyze trends across vitals, labs, symptoms
+ - Correlate genome data with medication responses
+ - Answer questions using YOUR specific health data
+
+
How it works:
+
+ - Connect your AI to inou (Claude via MCP bridge, Grok via API)
+ - Ask health questions naturally: "Why am I having headaches?" or "Is this medication safe for my genome?"
+ - AI sees your relevant data and gives personalized answers
+ - Data never leaves inou permanently - AI queries it in real-time
+
+
Monitor tier: MCP works, but AI has limited data (no labs/genome), so insights are basic
+
Optimize/Research: Full AI capabilities with complete health context
+
+
+
+
+
+ +
+ Can I import data from other apps?
+
+
+
+
+
Currently supported:
+
+ - Lab results: PDF upload with OCR
+ - Genome: 23andMe, AncestryDNA raw data files
+ - Medical imaging: DICOM files from radiology
+ - Photos: Medication bottles, food, health documents
+
+
Coming soon:
+
+ - Apple Health / HealthKit integration
+ - Google Fit integration
+ - Wearable devices (Garmin, Oura, Whoop)
+ - MyChart / Epic integration
+ - Laboratory portal direct imports
+
+
You can also enter data manually via text or voice for anything not yet automated.
+
+
+
+
+
+ +
+ Does inou replace my doctor?
+
+
+
+
+
No. inou is a tool for organizing your health data and enabling AI to help you understand it. It is NOT:
+
+ - A diagnostic tool
+ - A replacement for medical advice
+ - A treatment recommendation system
+ - A prescription service
+
+
inou helps you:
+
+ - Track your health comprehensively
+ - Understand patterns and trends
+ - Communicate better with your doctor (export reports for appointments)
+ - Research your conditions using AI with your personal context
+ - Manage medications and symptoms
+
+
Always consult your healthcare provider for medical decisions. Think of inou as your health data infrastructure - it makes you a more informed patient, but your doctor makes the clinical calls.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ Why is inou free until July 2026?
+
+
+
+
+
We're in active development and want early users to:
+
+ - Test features and give feedback while we refine the product
+ - Build their health data without worrying about costs
+ - Experience full value before committing to a subscription
+ - Help us improve by reporting bugs and suggesting features
+
+
Early adopters are incredibly valuable. This free period is our way of saying thank you for being part of the journey.
+
+
+
+
+
+ +
+ What happens on July 1, 2026?
+
+
+
+
+
We'll contact you before July 1st to ask if you want to continue:
+
+ - Continue with paid tier: Subscribe at the current pricing (prices locked for early users)
+ - Downgrade to Monitor (Free): Keep using basic features forever at no cost
+ - Export and leave: Download all your data and cancel
+
+
Important: Your subscription will NOT auto-renew. We will never charge you without explicit confirmation. No surprises, no sneaky billing.
+
+
+
+
+
+ +
+ Will prices increase after launch?
+
+
+
+
+
For early users who sign up during the free period:
+
+ - Pricing locked: If you subscribe after July 1st, you'll pay 2026 prices even if we raise them later
+ - Grandfathered forever: As long as you maintain continuous subscription, your rate never increases
+ - Example: If you subscribe at $12/mo in July 2026 and we raise prices to $15/mo in 2027, you still pay $12/mo
+
+
New users after July 2026 will pay whatever the current pricing is at that time.
+
+
+
+
+
+ +
+ Should I create multiple dossiers for myself?
+
+
+
+
+
No - build one comprehensive dossier with as much information as possible. The more data your AI has access to, the better insights it can provide.
+
You can create multiple dossiers under different email addresses, but there's no benefit to splitting your health data. Keep everything in one place for the best AI experience.
+
Use separate dossiers for family members, not for yourself.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ What payment methods do you accept?
+
+
+
+
+
+ - Credit cards (Visa, Mastercard, American Express, Discover)
+ - Debit cards
+ - Digital wallets (Apple Pay, Google Pay)
+ - ACH bank transfers (annual plans only)
+
+
Coming soon: PayPal, Venmo
+
+
+
+
+
+ +
+ Do you store my credit card information?
+
+
+
+
+
No. Payment processing is handled by Stripe, a certified PCI Service Provider Level 1 (the highest security standard). We never see or store your credit card details. Stripe handles all payment security.
+
+
+
+
+
+ +
+ When will I be charged?
+
+
+
+
+
Monthly plans:
+
+ - First charge: July 1, 2026 (or later if you sign up after that date)
+ - Recurring: Same day each month (if you subscribe on July 15th, you're billed the 15th of each month)
+ - Prorated: If you upgrade mid-cycle, you're charged the prorated difference immediately
+
+
Annual plans:
+
+ - First charge: July 1, 2026 (or later)
+ - Recurring: Same date each year
+ - No mid-year charges unless you upgrade tiers
+
+
+
+
+
+
+ +
+ Can I get a refund?
+
+
+
+
+
30-day money-back guarantee:
+
If you subscribe after July 1, 2026 and aren't satisfied, request a full refund within 30 days. No questions asked.
+
Free period users:
+
Since you used the service free for months before subscribing, refunds aren't available after the 30-day guarantee expires. You can always cancel to avoid future charges.
+
+
+
+
+
+ +
+ What happens if my payment fails?
+
+
+
+
+
+ - Day 1: Automatic retry
+ - Day 3: Email notification + retry
+ - Day 7: Final retry + account locked (read-only access)
+ - Day 14: Account suspended (no access until payment resolves)
+ - Day 30: Account scheduled for deletion
+
+
Your data is never deleted before 30 days, and we'll send multiple notifications. Update your payment method anytime to restore access immediately.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ How do I cancel my subscription?
+
+
+
+
+
+ - Go to Account Settings
+ - Click "Subscription" tab
+ - Click "Cancel Subscription"
+ - Confirm cancellation
+
+
Takes effect:
+
+ - End of current billing period (you keep access until then)
+ - Immediate downgrade to Monitor (Free) tier
+ - No further charges
+
+
Your data:
+
+ - Never deleted
+ - Remains stored and accessible in read-only mode
+ - Full access restored if you resubscribe
+
+
+
+
+
+
+ +
+ Can I reactivate after canceling?
+
+
+
+
+
Yes, anytime. Just:
+
+ - Go to Account Settings
+ - Click "Upgrade"
+ - Choose your plan and enter payment
+
+
Your data is still there - you'll have immediate access to everything again.
+
+
+
+
+
+ +
+ What if I want to delete my account completely?
+
+
+
+
+
Account deletion is permanent and immediate:
+
+ - Go to Account Settings
+ - Click "Delete Account"
+ - Confirm deletion (requires typing "DELETE" to confirm)
+ - All data is permanently destroyed within 24 hours
+
+
Before deleting:
+
+ - Export your data (we provide standard formats)
+ - Download any reports or documents you want to keep
+ - Consider canceling instead (keeps your data for future use)
+
+
After deletion:
+
+ - Cannot be undone
+ - Cannot recover any data
+ - Backups purged within 30 days
+
+
+
+
+
+
+ +
+ Can I export my data?
+
+
+
+
+
Yes, anytime. Export formats:
+
+ - Structured data: JSON, CSV
+ - Labs: PDF copies of original uploads
+ - Genome: Original raw data file
+ - Imaging: DICOM files
+ - Reports: PDF summaries with charts and trends
+
+
Export includes everything: vitals, symptoms, medications, labs, genome, imaging, notes - your complete health record.
+
Use cases:
+
+ - Switching to another service
+ - Sharing with healthcare providers
+ - Personal backup
+ - Research or analysis
+
+
You own your data. We just store it for you.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ Where is my data stored?
+
+
+
+
+
United States-based servers using enterprise-grade infrastructure:
+
+ - Not on Big Tech clouds (no Google, Amazon, Microsoft)
+ - Independent data centers with physical security
+ - Redundant backups across multiple locations
+ - HIPAA-compliant infrastructure
+
+
If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.
+
+
+
+
+
+ +
+ How is my data encrypted?
+
+
+
+
+
FIPS 140-3 encryption (US government standard):
+
+ - At rest: All files encrypted using FIPS 140-3 validated cryptography
+ - In transit: TLS 1.3 encryption for all connections
+ - Backups: Encrypted with separate keys
+ - Key management: Hardware security modules (HSMs)
+
+
This is the same encryption standard used by banks, hospitals, and government agencies.
+
+
+
+
+
+ +
+ Who can see my health data?
+
+
+
+
+
Only you. We never:
+
+ - Share data with advertisers
+ - Sell data to third parties
+ - Use data to train AI models
+ - Mine data for research without explicit consent
+ - Provide data to partners or affiliates
+
+
Law enforcement:
+
We comply with lawful requests (court orders, subpoenas) but nothing else. If served with a valid legal demand, we must provide requested data. We will notify you unless legally prohibited.
+
Your AI:
+
When you connect AI via MCP, your data is transmitted through an encrypted bridge to your AI session. The AI processes it in real-time but doesn't store it permanently. Check your AI provider's privacy policy for their data handling practices.
+
+
+
+
+
+ +
+ Can inou employees see my data?
+
+
+
+
+
No, except:
+
+ - You explicitly request support that requires data access
+ - Legal obligations (court order)
+ - Critical security incident investigation
+
+
When access is granted:
+
+ - Restricted to senior staff only
+ - Logged in audit trail (visible to you in Account Settings)
+ - Time-limited (access expires after 24 hours)
+ - You're notified when access occurs
+
+
Random employees, contractors, or developers never have access to your health data.
+
+
+
+
+
+ +
+ Do you use my data to train AI?
+
+
+
+
+
Never. Your data is:
+
+ - Not used to train machine learning models
+ - Not used to improve AI assistants
+ - Not used for research or development
+ - Not anonymized and aggregated for analysis
+
+
If we ever want to use anonymized, aggregated data for research, we will:
+
+ - Ask for explicit opt-in consent
+ - Explain exactly what we're studying
+ - Provide the ability to opt out anytime
+ - Never share identifiable data
+
+
+
+
+
+
+ +
+ What tracking do you use?
+
+
+
+
+
None. We don't use:
+
+ - Google Analytics
+ - Meta pixels
+ - Tracking scripts
+ - Third-party cookies
+ - Advertising networks
+
+
What we do track:
+
+ - One cookie for login session
+ - IP addresses for security logs only
+ - Error logs for debugging (no personal data)
+
+
We have no idea what you click, where you came from, or where you go next.
+
+
+
+
+
+ +
+ Is inou HIPAA compliant?
+
+
+
+
+
Yes. We follow HIPAA standards:
+
+ - Business Associate Agreements available for covered entities
+ - Administrative, physical, and technical safeguards
+ - Breach notification procedures
+ - Audit controls and access logs
+ - Encrypted storage and transmission
+
+
We also comply with:
+
+ - GDPR (European data protection)
+ - FADP (Swiss data protection)
+ - CCPA (California consumer privacy)
+
+
Regardless of where you live, you get our highest level of privacy protection.
+
+
+
+
+
+ +
+ What about children's privacy?
+
+
+
+
+
Users under 18:
+
+ - Cannot create accounts independently
+ - Require parent/guardian authorization
+ - Parent/guardian maintains full control
+ - Can be revoked anytime
+
+
Parents/guardians can:
+
+ - Create dossiers for children
+ - Manage all data and access
+ - Control sharing and AI integration
+ - Delete child's data anytime
+
+
Minors cannot share their data with third parties or connect AI without parental consent.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ What devices and platforms does inou support?
+
+
+
+
+
Web browser (all tiers):
+
+ - Chrome, Firefox, Safari, Edge
+ - Desktop and mobile browsers
+ - Responsive design for any screen size
+
+
Mobile apps (coming Q2 2026):
+
+ - iOS (iPhone, iPad)
+ - Android
+ - Native apps with full feature parity
+
+
Desktop apps (planned):
+
+
+
+
+
+
+ +
+ What AI assistants work with inou?
+
+
+
+
+
Currently supported:
+
+ - Claude (Anthropic) - via MCP bridge (.mcpb file)
+ - Grok (xAI) - via direct API
+
+
We're actively adding more integrations. See inou.com/connect for the latest list.
+
+
+
+
+
+ +
+ What if I find a bug?
+
+
+
+
+
Report it! We want to know:
+
+ - Email: support@inou.com
+ - In-app: Help → Report Bug
+ - Include screenshots if possible
+
+
Early users who report bugs help us build a better product. We fix critical issues within 24 hours and minor issues within a week.
+
+
+
+
+
+ +
+ Do you offer customer support?
+
+
+
+
+
Yes:
+
+ - Email support: support@inou.com (24-48 hour response)
+ - This FAQ page
+ - Setup guides at inou.com/connect
+
+
+
+
+
+
+ +
+ Can I request features?
+
+
+
+
+
Absolutely! Email features@inou.com or use the in-app feedback tool. We maintain a public roadmap and regularly implement user suggestions.
+
Early users have significant influence on product direction - if enough people want a feature, we prioritize it.
+
+
+
+
+
+ +
+ What if inou shuts down?
+
+
+
+
+
We commit to:
+
+ - 90 days advance notice
+ - Export tools for all data
+ - Recommendations for alternative services
+ - Option to self-host your data
+
+
If acquired, your data either:
+
+ - Transfers under the same privacy terms, OR
+ - You're given the option to export and delete
+
+
We will never sell user data as part of an acquisition. It's your data, not our asset.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ How do I sign up?
+
+
+
+
+
+ - Go to inou.com
+ - Enter your email address
+ - Click the verification link sent to your inbox
+ - Create your first dossier
+ - Start tracking!
+
+
No passwords to remember - we use secure email verification.
+
No credit card required during the free period.
+
+
+
+
+
+ +
+ What should I track first?
+
+
+
+
+
Quick wins:
+
+ - Current medications - Get AI drug interaction checking
+ - Recent lab results - Upload last bloodwork PDF
+ - Vitals baseline - Blood pressure, weight, resting heart rate
+ - Symptoms if any - Helps establish patterns
+
+
Over time:
+
+ - Upload genome data if you have it (23andMe, Ancestry)
+ - Track period/exercise/food as relevant
+ - Add family history for context
+ - Upload medical imaging for complex conditions
+
+
Start simple - you can always add more later.
+
+
+
+
+
+ +
+ How long does genome analysis take?
+
+
+
+
+
Instantly. Upload takes about 30 seconds (file is ~20MB), then analysis completes in seconds. Results appear immediately - no waiting, no email notification needed.
+
+
+
+
+
+ +
+ Can I import old medical records?
+
+
+
+
+
Yes, but it depends on format:
+
+ - PDFs: Upload directly (labs, imaging reports, doctor notes)
+ - Paper records: Take photos, upload via mobile app
+ - CDs from radiology: DICOM files work natively
+ - Portals (MyChart, etc.): Manual download, then upload
+
+
We're working on direct integrations with Epic, Cerner, and other EHR systems.
+
+
+
+
+
+ +
+ How do I connect my AI?
+
+
+
+
+
Claude Desktop:
+
+ - Download the inou MCP bridge from your dashboard
+ - Double-click the .mcpb file to install
+ - Restart Claude Desktop
+ - Ask Claude health questions - it now has access to your dossier!
+
+
Grok:
+
Grok connects directly via API. Go to your dashboard, generate an API token, and add it to Grok's settings.
+
Detailed setup guides: inou.com/connect
+
+
+
+
+
+
+
+
+
Still Have Questions?
+
We're a small team building something we believe in. If you have questions, ideas, or feedback - we want to hear it.
+
+ Email us: support@inou.com
+
+
+
+
+ {{template "footer"}}
+
+{{end}}
diff --git a/templates/footer.tmpl b/templates/footer.tmpl
new file mode 100644
index 0000000..62c0d1e
--- /dev/null
+++ b/templates/footer.tmpl
@@ -0,0 +1,10 @@
+{{define "footer"}}
+
+{{end}}
diff --git a/templates/input.tmpl b/templates/input.tmpl
new file mode 100644
index 0000000..6e29e49
--- /dev/null
+++ b/templates/input.tmpl
@@ -0,0 +1,230 @@
+{{define "input"}}
+
+
+
+
+
+
+
+ Add Health Data — inou
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/templates/install_public.tmpl b/templates/install_public.tmpl
new file mode 100644
index 0000000..3d6d85a
--- /dev/null
+++ b/templates/install_public.tmpl
@@ -0,0 +1,235 @@
+{{define "install_public"}}
+
+
+
+
+
+
+
Note: Sign in to see personalized setup instructions with your account token pre-filled.
+
+
+
+
+
+
+
+
+
+
+
+
Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.
+
+
+
+
+
2
+
Install Desktop Commander
+
Open Claude Desktop and paste:
+
+
Please install Desktop Commander MCP server so you can help me with file operations.
+
+
+
Claude will guide you through the installation. Restart Claude when done.
+
+
+
+
3
+
Install Inou Bridge
+
After restarting, paste this in Claude:
+
+
Please set up the Inou medical imaging bridge:
+
+1. Detect my OS and architecture
+
+2. Download the correct bridge:
+ - Mac Apple Silicon: https://inou.com/download/inou_bridge_darwin_arm64
+ - Mac Intel: https://inou.com/download/inou_bridge_darwin_amd64
+ - Windows 64-bit: https://inou.com/download/inou_bridge_win_amd64.exe
+
+3. Save it to:
+ - Mac: ~/bin/inou_bridge (create ~/bin if needed, make executable)
+ - Windows: %USERPROFILE%\inou\inou_bridge.exe (create folder if needed)
+
+4. Edit Claude config:
+ - Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
+ - Windows: %APPDATA%\Claude\claude_desktop_config.json
+
+5. Add to mcpServers (keep any existing entries like desktop-commander):
+
+"inou": {
+ "command": "BRIDGE_PATH",
+ "args": ["--server=https://inou.com", "--account=YOUR_ACCOUNT_TOKEN"]
+}
+
+Replace BRIDGE_PATH with the actual path where you saved the bridge.
+Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+
+Tell me when done.
+
+
+
+
+
+
4
+
Restart & Test
+
Quit Claude Desktop completely, reopen, then paste:
+
+
List my available dossiers using the inou bridge, then show imaging studies for the first one.
+
+
+
You should see your dossier(s) and any imaging studies.
+
+
+
+
+
+
Grok can browse the web and access your health data directly through our API. No installation needed.
+
+
+
Quick Start
+
Copy this and paste it into Grok:
+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token=YOUR_ACCOUNT_TOKEN to list my dossiers.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+
+
+
+
+
+
→
+
What Grok can do
+
Once connected, ask Grok to:
+
+ - List all your imaging studies, genome data, and lab results
+ - Show series within a specific study
+ - Fetch and analyze individual slices
+ - Compare images across different sequences (T1, T2, FLAIR)
+ - Navigate to specific anatomical regions
+ - Query genome variants by gene, category, or rsid
+ - Review medication responses and health risks
+ - Track lab values over time
+
+
+
+
See the full API documentation for all available endpoints.
+
+
+
+
+
Not recommended for medical imaging due to elevated hallucination risk in our testing.
+
+
+
✗
+
Why not ChatGPT?
+
Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.
+
+
+
+
→
+
Recommended alternatives
+
Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.
+
+
+
+
+
+
Other AI assistants can access your data through our web API, though capabilities vary.
+
+
+
⚠
+
Gemini
+
Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:
+
+ - Copy API responses manually and paste them into Gemini
+ - Use Google AI Studio with function calling
+ - Consider using Claude Desktop or Grok instead
+
+
+
+
+
→
+
Build Your Own
+
Our API is simple REST + JSON. See the API documentation for endpoints and authentication.
+
+
+
+
+
+{{end}}
diff --git a/templates/invite.tmpl b/templates/invite.tmpl
new file mode 100644
index 0000000..be0acdc
--- /dev/null
+++ b/templates/invite.tmpl
@@ -0,0 +1,69 @@
+{{define "invite"}}
+
+
+
+
+
Invite a friend
+
Know someone who could benefit from inou? Send them an invitation.
+
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+ {{if .Success}}
+
{{.Success}}
+ {{end}}
+
+
+
+
+
What happens next?
+
We'll send them a personal email from you with an invitation to join inou. That's it.
+
+ - We won't email them again
+ - We won't store their email if they don't sign up
+ - We won't share their email with anyone
+ - We'll send you a copy of the email, so you see exactly what we sent
+
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/landing.tmpl b/templates/landing.tmpl
new file mode 100644
index 0000000..c4909c4
--- /dev/null
+++ b/templates/landing.tmpl
@@ -0,0 +1,423 @@
+{{define "landing"}}
+
+
+
+
+
+
+
inou organizes and shares your health dossier with your AI — securely and privately.
+
Your health, understood.
+
+
+
+
+
+
+
You need AI for your health
+
+
+
Your health data lives in a dozen different places — with your cardiologist, your neurologist, your lab, your watch, your apps, your 23andMe. And only you know the rest: what you eat, what you drink, what supplements you take. Your exercise routine. Your symptoms. Your goals — whether you're trying to get pregnant, training for a marathon, or just trying to feel less exhausted.
+
+
Whether you're healthy and want to stay that way, navigating a difficult diagnosis, or caring for a family member who can't advocate for themselves — no single doctor sees the full picture. No system connects it.
+
+
But you have access to all of it. You just don't have the expertise to make sense of it all.
+
+
Your AI does. inou gives it the full picture.
+
+
+
+
+
+
The challenge
+
+
Your MRI has 4,000 slices.
+
It was read in 10 minutes.
+
+
+
+
Your genome has millions of variants.
+
All you learned was your eye color and where your ancestors came from.
+
+
+
+
Your blood work has dozens of markers.
+
Your doctor said "everything looks fine."
+
+
+
+
Your watch tracked 10,000 hours of sleep.
+
Your trainer doesn't know it exists.
+
+
+
+
You've tried a hundred different supplements.
+
Nobody asked which ones.
+
+
+
+ The connections are there.
+ They are just too complex for any one person to grasp.
+
+
+
+ Nobody knows how your body processes Warfarin — not even you.
+ But the answer might already be hiding in your 23andMe.
+ That 'unremarkable' on your MRI — did anyone look closely at all 4,000 slices?
+ Your thyroid is 'in range' — but nobody connected it to your fatigue, your weight, always being cold.
+
+
+
+ Nobody is connecting your afternoon caffeine to your sleep scores.
+ Your iron levels to your workout fatigue.
+ Your genetics to your brain fog.
+
+
+
+ Your AI doesn't forget.
+ Doesn't rush.
+ Finds what was missed.
+ Doesn't specialize — sees the complete you.
+
+
+
inou lets your AI take it all into account — every slice, every marker, every variant — connect it all and finally give you answers no one else could.
+
+
+
+
+
+
+
Why we built this
+
+
You've collected years of health data. Scans from the hospital. Blood work from the lab. Results from your doctor's portal. Data from your watch. Maybe even your DNA.
+
+
And then there's everything only you know — your weight, your blood pressure, your training schedule, the supplements you take, the symptoms you've been meaning to mention.
+
+
It's all there — but scattered across systems that don't talk to each other, held by specialists who only see their piece, or locked in your own head.
+
+
Your cardiologist doesn't know what your neurologist found. Your trainer hasn't seen your blood work. Your doctor has no idea what supplements you are taking. And none of them have time to sit with you and connect the dots.
+
+
AI finally can. It can pull together what no single expert sees — and actually explain it to you.
+
+
But this data doesn't fit in a chat window. And the last thing you want is your medical history on someone else's servers, training their models.
+
+
inou brings it all together — labs, imaging, genetics, vitals, medications, supplements — encrypted, private, and shared with absolutely no one. Your AI connects securely. Your data stays yours.
+
+
Your health, understood.
+
+
+
+
+
{{.T.data_yours}}
+
+
+ {{.T.never_training}}
+ {{.T.never_training_desc}}
+
+
+ {{.T.never_shared}}
+ {{.T.never_shared_desc}}
+
+
+ {{.T.encrypted}}
+ {{.T.encrypted_desc}}
+
+
+ {{.T.delete}}
+ {{.T.delete_desc}}
+
+
+
+
+ {{template "footer"}}
+
+
+{{end}}
+
+
diff --git a/templates/landing_da.tmpl b/templates/landing_da.tmpl
new file mode 100644
index 0000000..d1e806b
--- /dev/null
+++ b/templates/landing_da.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_da"}}
+
+
+
+
+
inou organiserer og deler dit sundhedsdossier med din AI — sikkert og privat.
+
Dit helbred, forstået.
+
+
+
+
+
+
Du har brug for AI til dit helbred
+
+
Dine sundhedsdata er spredt på dusinvis af steder — hos din kardiolog, din neurolog, laboratoriet, dit smartur, dine apps, dit 23andMe. Og kun du kender resten: hvad du spiser, hvad du drikker, hvilke kosttilskud du tager. Din træningsrutine. Dine symptomer. Dine mål — uanset om du prøver at blive gravid, træner til et maraton, eller bare prøver at føle dig mindre træt.
+
Uanset om du er sund og vil blive ved med det, navigerer en svær diagnose, eller passer på et familiemedlem der ikke kan tale for sig selv — ingen enkelt læge ser hele billedet. Ingen system forbinder det hele.
+
Men du har adgang til alt. Du mangler bare ekspertisen til at forstå det.
+
Din AI har den. inou giver den hele billedet.
+
+
+
+
+
+
Udfordringen
+
Din MR-scanning har 4.000 snit.
Den blev læst på 10 minutter.
+
Dit genom har millioner af varianter.
Du fik kun at vide din øjenfarve og hvor dine forfædre kom fra.
+
Dine blodprøver har dusinvis af markører.
Din læge sagde "alt ser fint ud."
+
Dit ur har registreret 10.000 timers søvn.
Din træner ved ikke at det eksisterer.
+
Du har prøvet hundrede forskellige kosttilskud.
Ingen spurgte hvilke.
+
Forbindelserne er der.
De er bare for komplekse for én person.
+
+ Ingen ved hvordan din krop behandler Warfarin — ikke engang dig.
+ Men svaret gemmer sig måske allerede i dit 23andMe.
+ Det "uden bemærkninger" på din MR — kiggede nogen virkelig grundigt på alle 4.000 snit?
+ Din skjoldbruskkirtel er "inden for normalområdet" — men ingen forbandt det med din træthed, din vægt, at du altid fryser.
+
+
+ Ingen forbinder din eftermiddagskaffe med din søvnkvalitet.
+ Dine jernværdier med din træthed under træning.
+ Din genetik med din hjernetåge.
+
+
+ Din AI glemmer ikke.
+ Har ikke travlt.
+ Finder det der blev overset.
+ Specialiserer sig ikke — ser dig som helhed.
+
+
inou lader din AI tage alt i betragtning — hvert snit, hver markør, hver variant — forbinder det hele og giver dig endelig svar som ingen andre kunne give.
+
+
+
+
+
Hvorfor vi byggede dette
+
Du har samlet års sundhedsdata. Undersøgelser fra hospitalet. Prøver fra laboratoriet. Resultater fra patientportalen. Data fra dit ur. Måske endda dit DNA.
+
Og så er der alt det som kun du ved — din vægt, dit blodtryk, dit træningsprogram, de tilskud du tager, de symptomer du altid glemmer at nævne.
+
Det er alt sammen der — men spredt i systemer der ikke taler sammen, hos specialister der kun ser deres del, eller låst inde i dit eget hoved.
+
Din kardiolog ved ikke hvad din neurolog fandt. Din træner har ikke set dine blodprøver. Din læge aner ikke hvilke tilskud du tager. Og ingen af dem har tid til at sætte sig ned med dig og forbinde prikkerne.
+
AI kan endelig gøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for dig oven i købet.
+
Men disse data passer ikke i et chatvindue. Og det sidste du vil er din sygehistorie på andres servere, til at træne deres modeller.
+
inou samler alt — lab, billeddiagnostik, genetik, vitale tegn, medicin, tilskud — krypteret, privat, og delt med absolut ingen. Din AI forbinder sikkert. Dine data forbliver dine.
+
Dit helbred, forstået.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_de.tmpl b/templates/landing_de.tmpl
new file mode 100644
index 0000000..430682d
--- /dev/null
+++ b/templates/landing_de.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_de"}}
+
+
+
+
+
inou organisiert und teilt Ihre Gesundheitsakte mit Ihrer KI — sicher und privat.
+
Ihre Gesundheit, verstanden.
+
+
+
+
+
+
Sie brauchen KI für Ihre Gesundheit
+
+
Ihre Gesundheitsdaten sind über Dutzende von Orten verstreut — bei Ihrem Kardiologen, Ihrem Neurologen, im Labor, in Ihrer Smartwatch, Ihren Apps, Ihrem 23andMe. Und nur Sie kennen den Rest: was Sie essen, was Sie trinken, welche Nahrungsergänzungsmittel Sie nehmen. Ihr Trainingsplan. Ihre Symptome. Ihre Ziele — ob Sie schwanger werden möchten, für einen Marathon trainieren oder einfach weniger müde sein wollen.
+
Ob Sie gesund sind und es bleiben wollen, mit einer schwierigen Diagnose kämpfen oder sich um ein Familienmitglied kümmern, das sich nicht selbst vertreten kann — kein einzelner Arzt sieht das vollständige Bild. Kein System verbindet alles.
+
Aber Sie haben Zugang zu allem. Ihnen fehlt nur die Expertise, um alles zu verstehen.
+
Ihre KI hat sie. inou gibt ihr das vollständige Bild.
+
+
+
+
+
+
Die Herausforderung
+
Ihr MRT hat 4.000 Schichten.
Es wurde in 10 Minuten ausgewertet.
+
Ihr Genom hat Millionen von Varianten.
Sie haben nur Ihre Augenfarbe und Ihre Herkunft erfahren.
+
Ihr Blutbild hat Dutzende von Markern.
Ihr Arzt sagte "alles sieht gut aus."
+
Ihre Uhr hat 10.000 Stunden Schlaf aufgezeichnet.
Ihr Trainer weiß nicht, dass sie existiert.
+
Sie haben hundert verschiedene Nahrungsergänzungsmittel ausprobiert.
Niemand hat gefragt, welche.
+
Die Verbindungen sind da.
Sie sind nur zu komplex für eine einzelne Person.
+
+ Niemand weiß, wie Ihr Körper Warfarin verarbeitet — nicht einmal Sie.
+ Aber die Antwort könnte bereits in Ihrem 23andMe versteckt sein.
+ Dieses "unauffällig" in Ihrem MRT — hat jemand wirklich alle 4.000 Schichten genau angesehen?
+ Ihre Schilddrüse ist "im Normbereich" — aber niemand hat sie mit Ihrer Müdigkeit, Ihrem Gewicht, dass Ihnen immer kalt ist, verbunden.
+
+
+ Niemand verbindet Ihren Nachmittagskaffee mit Ihrer Schlafqualität.
+ Ihren Eisenspiegel mit Ihrer Trainingsmüdigkeit.
+ Ihre Genetik mit Ihrem Gehirnnebel.
+
+
+ Ihre KI vergisst nicht.
+ Hetzt nicht.
+ Findet, was übersehen wurde.
+ Spezialisiert sich nicht — sieht Sie als Ganzes.
+
+
inou lässt Ihre KI alles berücksichtigen — jede Schicht, jeden Marker, jede Variante — verbindet alles und gibt Ihnen endlich Antworten, die niemand sonst geben konnte.
+
+
+
+
+
Warum wir das gebaut haben
+
Sie haben jahrelang Gesundheitsdaten gesammelt. Scans aus dem Krankenhaus. Blutwerte aus dem Labor. Ergebnisse aus dem Patientenportal. Daten von Ihrer Uhr. Vielleicht sogar Ihre DNA.
+
Und dann gibt es alles, was nur Sie wissen — Ihr Gewicht, Ihr Blutdruck, Ihr Trainingsplan, die Nahrungsergänzungsmittel, die Sie nehmen, die Symptome, die Sie immer vergessen zu erwähnen.
+
Es ist alles da — aber verstreut über Systeme, die nicht miteinander kommunizieren, bei Spezialisten, die nur ihren Teil sehen, oder in Ihrem eigenen Kopf eingeschlossen.
+
Ihr Kardiologe weiß nicht, was Ihr Neurologe gefunden hat. Ihr Trainer hat Ihre Blutwerte nicht gesehen. Ihr Arzt hat keine Ahnung, welche Nahrungsergänzungsmittel Sie nehmen. Und keiner von ihnen hat Zeit, sich mit Ihnen hinzusetzen und die Punkte zu verbinden.
+
KI kann das endlich. Sie kann zusammenführen, was kein einzelner Experte sieht — und es Ihnen auch noch erklären.
+
Aber diese Daten passen nicht in ein Chat-Fenster. Und das Letzte, was Sie wollen, ist Ihre Krankengeschichte auf fremden Servern, die deren Modelle trainiert.
+
inou bringt alles zusammen — Labor, Bildgebung, Genetik, Vitalwerte, Medikamente, Nahrungsergänzungsmittel — verschlüsselt, privat und mit niemandem geteilt. Ihre KI verbindet sich sicher. Ihre Daten bleiben Ihre.
+
Ihre Gesundheit, verstanden.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_es.tmpl b/templates/landing_es.tmpl
new file mode 100644
index 0000000..525d4d6
--- /dev/null
+++ b/templates/landing_es.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_es"}}
+
+
+
+
+
inou organiza y comparte tu expediente de salud con tu IA — de forma segura y privada.
+
Tu salud, comprendida.
+
+
+
+
+
+
Necesitas IA para tu salud
+
+
Tus datos de salud están dispersos en docenas de lugares — con tu cardiólogo, tu neurólogo, el laboratorio, tu reloj inteligente, tus apps, tu 23andMe. Y solo tú conoces el resto: qué comes, qué bebes, qué suplementos tomas. Tu rutina de ejercicio. Tus síntomas. Tus objetivos — ya sea que estés intentando quedar embarazada, entrenando para un maratón, o simplemente tratando de sentirte menos cansado.
+
Ya sea que estés sano y quieras seguir así, navegando un diagnóstico difícil, o cuidando a un familiar que no puede defenderse solo — ningún médico ve el panorama completo. Ningún sistema lo conecta.
+
Pero tú tienes acceso a todo. Solo te falta la experiencia para entenderlo todo.
+
Tu IA la tiene. inou le da el panorama completo.
+
+
+
+
+
+
El desafío
+
Tu resonancia tiene 4.000 cortes.
Se leyó en 10 minutos.
+
Tu genoma tiene millones de variantes.
Solo aprendiste el color de tus ojos y de dónde vienen tus ancestros.
+
Tu análisis de sangre tiene docenas de marcadores.
Tu médico dijo "todo se ve bien."
+
Tu reloj registró 10.000 horas de sueño.
Tu entrenador no sabe que existe.
+
Has probado cien suplementos diferentes.
Nadie preguntó cuáles.
+
Las conexiones están ahí.
Son demasiado complejas para una sola persona.
+
+ Nadie sabe cómo tu cuerpo procesa la Warfarina — ni siquiera tú.
+ Pero la respuesta podría estar escondida en tu 23andMe.
+ Ese "sin hallazgos" en tu resonancia — ¿alguien miró cuidadosamente los 4.000 cortes?
+ Tu tiroides está "dentro del rango" — pero nadie lo conectó con tu fatiga, tu peso, que siempre tienes frío.
+
+
+ Nadie conecta tu café de la tarde con tu calidad de sueño.
+ Tus niveles de hierro con tu fatiga en el entrenamiento.
+ Tu genética con tu niebla mental.
+
+
+ Tu IA no olvida.
+ No se apresura.
+ Encuentra lo que se pasó por alto.
+ No se especializa — te ve completo.
+
+
inou permite que tu IA tome todo en cuenta — cada corte, cada marcador, cada variante — conecta todo y finalmente te da respuestas que nadie más podía dar.
+
+
+
+
+
Por qué construimos esto
+
Has recopilado años de datos de salud. Estudios del hospital. Análisis del laboratorio. Resultados del portal del médico. Datos de tu reloj. Quizás incluso tu ADN.
+
Y luego está todo lo que solo tú sabes — tu peso, tu presión arterial, tu programa de entrenamiento, los suplementos que tomas, los síntomas que siempre olvidas mencionar.
+
Todo está ahí — pero disperso en sistemas que no se comunican, con especialistas que solo ven su parte, o encerrado en tu propia cabeza.
+
Tu cardiólogo no sabe lo que encontró tu neurólogo. Tu entrenador no ha visto tus análisis de sangre. Tu médico no tiene idea de qué suplementos tomas. Y ninguno de ellos tiene tiempo para sentarse contigo y conectar los puntos.
+
La IA finalmente puede. Puede unir lo que ningún experto solo ve — y además explicártelo.
+
Pero estos datos no caben en una ventana de chat. Y lo último que quieres es tu historial médico en los servidores de alguien más, entrenando sus modelos.
+
inou lo une todo — laboratorio, imágenes, genética, signos vitales, medicamentos, suplementos — encriptado, privado, y sin compartir con absolutamente nadie. Tu IA se conecta de forma segura. Tus datos siguen siendo tuyos.
+
Tu salud, comprendida.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_fi.tmpl b/templates/landing_fi.tmpl
new file mode 100644
index 0000000..78fbcc1
--- /dev/null
+++ b/templates/landing_fi.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_fi"}}
+
+
+
+
+
inou järjestää ja jakaa terveystietosi tekoälysi kanssa — turvallisesti ja yksityisesti.
+
Terveytesi, ymmärrettynä.
+
+
+
+
+
+
Tarvitset tekoälyä terveytesi tueksi
+
+
Terveystietosi ovat hajallaan kymmenissä paikoissa — kardiologillasi, neurologillasi, laboratoriossa, älykellossasi, sovelluksissasi, 23andMe:ssä. Ja vain sinä tiedät loput: mitä syöt, mitä juot, mitä lisäravinteita otat. Harjoitteluohjelmasi. Oireesi. Tavoitteesi — yrititpä tulla raskaaksi, harjoitella maratoniin tai vain yrität tuntea itsesi vähemmän väsyneeksi.
+
Olitpa terve ja haluat pysyä sellaisena, navigoit vaikeaa diagnoosia tai hoidat perheenjäsentä joka ei pysty puolustamaan itseään — yksikään lääkäri ei näe kokonaiskuvaa. Mikään järjestelmä ei yhdistä kaikkea.
+
Mutta sinulla on pääsy kaikkeen. Sinulta puuttuu vain asiantuntemus ymmärtääksesi kaiken.
+
Tekoälylläsi se on. inou antaa sille kokonaiskuvan.
+
+
+
+
+
+
Haaste
+
MRI-kuvauksessasi on 4 000 leikettä.
Se luettiin 10 minuutissa.
+
Genomissasi on miljoonia variantteja.
Sait tietää vain silmiesi värin ja mistä esi-isäsi tulivat.
+
Verikokeissasi on kymmeniä merkkiaineita.
Lääkärisi sanoi "kaikki näyttää hyvältä."
+
Kellosi on tallentanut 10 000 tuntia unta.
Valmentajasi ei tiedä sen olemassaolosta.
+
Olet kokeillut sataa eri lisäravinnetta.
Kukaan ei kysynyt mitä.
+
Yhteydet ovat siellä.
Ne ovat vain liian monimutkaisia yhdelle ihmiselle.
+
+ Kukaan ei tiedä miten kehosi käsittelee Warfariinia — et sinäkään.
+ Mutta vastaus saattaa piillä 23andMe:ssäsi.
+ Se "ei poikkeavaa" MRI:ssäsi — katsottiinko todella kaikki 4 000 leikettä huolellisesti?
+ Kilpirauhasesi on "viitearvoissa" — mutta kukaan ei yhdistänyt sitä väsymykseesi, painoosi, siihen että sinulla on aina kylmä.
+
+
+ Kukaan ei yhdistä iltapäiväkahviasi unen laatuusi.
+ Rautatasojasi harjoitusväsymykseesi.
+ Genetiikkaasi aivosumuusi.
+
+
+ Tekoälysi ei unohda.
+ Ei kiirehdi.
+ Löytää sen mikä jäi huomaamatta.
+ Ei erikoistu — näkee sinut kokonaisuutena.
+
+
inou antaa tekoälysi ottaa kaiken huomioon — jokaisen leikkeen, jokaisen merkkiaineen, jokaisen variantin — yhdistää kaiken ja antaa sinulle vihdoin vastauksia joita kukaan muu ei voinut antaa.
+
+
+
+
+
Miksi rakensimme tämän
+
Olet kerännyt vuosien terveystietoja. Tutkimuksia sairaalasta. Kokeita laboratoriosta. Tuloksia potilasportaalista. Dataa kellostasi. Ehkä jopa DNA:si.
+
Ja sitten on kaikki mitä vain sinä tiedät — painosi, verenpaineesi, harjoitusohjelmasi, ottamasi lisäravinteet, oireet jotka unohdat aina mainita.
+
Kaikki on siellä — mutta hajallaan järjestelmissä jotka eivät keskustele keskenään, erikoislääkäreillä jotka näkevät vain oman osansa, tai lukittuna omaan päähäsi.
+
Kardiologisi ei tiedä mitä neurologisi löysi. Valmentajasi ei ole nähnyt verikokeittasi. Lääkärisi ei tiedä mitä lisäravinteita otat. Eikä kenelläkään heistä ole aikaa istua alas kanssasi ja yhdistää pisteitä.
+
Tekoäly vihdoin pystyy. Se voi koota yhteen sen mitä yksikään yksittäinen asiantuntija ei näe — ja selittää sen sinulle.
+
Mutta nämä tiedot eivät mahdu chat-ikkunaan. Ja viimeinen asia mitä haluat on sairaushistoriasi jonkun muun palvelimilla, kouluttamassa heidän mallejaan.
+
inou kokoaa kaiken yhteen — laboratorio, kuvantaminen, genetiikka, vitaalit, lääkkeet, lisäravinteet — salattuna, yksityisenä, eikä jaeta kenellekään. Tekoälysi yhdistää turvallisesti. Tietosi pysyvät sinun.
+
Terveytesi, ymmärrettynä.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_fr.tmpl b/templates/landing_fr.tmpl
new file mode 100644
index 0000000..87740d1
--- /dev/null
+++ b/templates/landing_fr.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_fr"}}
+
+
+
+
+
inou organise et partage votre dossier santé avec votre IA — en toute sécurité et confidentialité.
+
Votre santé, comprise.
+
+
+
+
+
+
Vous avez besoin de l'IA pour votre santé
+
+
Vos données de santé sont dispersées dans des dizaines d'endroits — chez votre cardiologue, votre neurologue, le laboratoire, votre montre connectée, vos applications, votre 23andMe. Et vous seul connaissez le reste : ce que vous mangez, ce que vous buvez, quels compléments vous prenez. Votre programme d'entraînement. Vos symptômes. Vos objectifs — que vous essayiez de tomber enceinte, de vous préparer pour un marathon, ou simplement de vous sentir moins fatigué.
+
Que vous soyez en bonne santé et vouliez le rester, que vous naviguiez un diagnostic difficile, ou que vous vous occupiez d'un proche qui ne peut pas se défendre seul — aucun médecin ne voit le tableau complet. Aucun système ne connecte tout.
+
Mais vous avez accès à tout. Il vous manque juste l'expertise pour tout comprendre.
+
Votre IA l'a. inou lui donne le tableau complet.
+
+
+
+
+
+
Le défi
+
Votre IRM contient 4 000 coupes.
Elle a été lue en 10 minutes.
+
Votre génome contient des millions de variants.
Vous n'avez appris que la couleur de vos yeux et l'origine de vos ancêtres.
+
Votre bilan sanguin contient des dizaines de marqueurs.
Votre médecin a dit "tout va bien."
+
Votre montre a enregistré 10 000 heures de sommeil.
Votre coach ne sait pas qu'elle existe.
+
Vous avez essayé une centaine de compléments différents.
Personne n'a demandé lesquels.
+
Les connexions sont là.
Elles sont juste trop complexes pour une seule personne.
+
+ Personne ne sait comment votre corps métabolise la Warfarine — pas même vous.
+ Mais la réponse se cache peut-être déjà dans votre 23andMe.
+ Ce "sans particularité" sur votre IRM — quelqu'un a-t-il vraiment regardé les 4 000 coupes attentivement ?
+ Votre thyroïde est "dans les normes" — mais personne n'a fait le lien avec votre fatigue, votre poids, le fait que vous avez toujours froid.
+
+
+ Personne ne relie votre café de l'après-midi à votre qualité de sommeil.
+ Votre taux de fer à votre fatigue à l'entraînement.
+ Votre génétique à votre brouillard mental.
+
+
+ Votre IA n'oublie pas.
+ Ne se précipite pas.
+ Trouve ce qui a été manqué.
+ Ne se spécialise pas — vous voit dans votre globalité.
+
+
inou permet à votre IA de tout prendre en compte — chaque coupe, chaque marqueur, chaque variant — de tout connecter et de vous donner enfin des réponses que personne d'autre ne pouvait donner.
+
+
+
+
+
Pourquoi nous avons créé ça
+
Vous avez collecté des années de données de santé. Des examens de l'hôpital. Des analyses du laboratoire. Des résultats du portail patient. Des données de votre montre. Peut-être même votre ADN.
+
Et puis il y a tout ce que vous seul savez — votre poids, votre tension, votre programme d'entraînement, les compléments que vous prenez, les symptômes que vous oubliez toujours de mentionner.
+
Tout est là — mais dispersé dans des systèmes qui ne communiquent pas entre eux, chez des spécialistes qui ne voient que leur partie, ou enfermé dans votre propre tête.
+
Votre cardiologue ne sait pas ce que votre neurologue a trouvé. Votre coach n'a pas vu vos analyses sanguines. Votre médecin n'a aucune idée des compléments que vous prenez. Et aucun d'entre eux n'a le temps de s'asseoir avec vous pour relier les points.
+
L'IA peut enfin le faire. Elle peut rassembler ce qu'aucun expert seul ne voit — et vous l'expliquer en plus.
+
Mais ces données ne tiennent pas dans une fenêtre de chat. Et la dernière chose que vous voulez, c'est votre historique médical sur les serveurs de quelqu'un d'autre, entraînant leurs modèles.
+
inou rassemble tout — analyses, imagerie, génétique, constantes, médicaments, compléments — chiffré, privé, et partagé avec absolument personne. Votre IA se connecte en toute sécurité. Vos données restent les vôtres.
+
Votre santé, comprise.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_it.tmpl b/templates/landing_it.tmpl
new file mode 100644
index 0000000..91da4c0
--- /dev/null
+++ b/templates/landing_it.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_it"}}
+
+
+
+
+
inou organizza e condivide il tuo dossier sanitario con la tua IA — in modo sicuro e privato.
+
La tua salute, compresa.
+
+
+
+
+
+
Hai bisogno dell'IA per la tua salute
+
+
I tuoi dati sanitari sono sparsi in decine di posti — dal cardiologo, dal neurologo, nel laboratorio, nel tuo smartwatch, nelle tue app, nel tuo 23andMe. E solo tu conosci il resto: cosa mangi, cosa bevi, quali integratori prendi. La tua routine di allenamento. I tuoi sintomi. I tuoi obiettivi — che tu stia cercando di rimanere incinta, allenarti per una maratona, o semplicemente sentirti meno stanco.
+
Che tu sia in salute e voglia restarlo, stia affrontando una diagnosi difficile, o ti stia prendendo cura di un familiare che non può difendersi da solo — nessun medico vede il quadro completo. Nessun sistema connette tutto.
+
Ma tu hai accesso a tutto. Ti manca solo l'esperienza per capire tutto.
+
La tua IA ce l'ha. inou le dà il quadro completo.
+
+
+
+
+
+
La sfida
+
La tua risonanza ha 4.000 sezioni.
È stata letta in 10 minuti.
+
Il tuo genoma ha milioni di varianti.
Hai scoperto solo il colore dei tuoi occhi e da dove vengono i tuoi antenati.
+
Le tue analisi del sangue hanno decine di marcatori.
Il tuo medico ha detto "tutto bene."
+
Il tuo orologio ha registrato 10.000 ore di sonno.
Il tuo trainer non sa che esiste.
+
Hai provato cento integratori diversi.
Nessuno ha chiesto quali.
+
Le connessioni ci sono.
Sono troppo complesse per una sola persona.
+
+ Nessuno sa come il tuo corpo metabolizza il Warfarin — nemmeno tu.
+ Ma la risposta potrebbe essere nascosta nel tuo 23andMe.
+ Quel "nella norma" nella tua risonanza — qualcuno ha davvero guardato tutte le 4.000 sezioni con attenzione?
+ La tua tiroide è "nei range" — ma nessuno l'ha collegata alla tua stanchezza, al tuo peso, al fatto che hai sempre freddo.
+
+
+ Nessuno collega il tuo caffè pomeridiano alla qualità del tuo sonno.
+ I tuoi livelli di ferro alla tua stanchezza in allenamento.
+ La tua genetica alla tua nebbia mentale.
+
+
+ La tua IA non dimentica.
+ Non ha fretta.
+ Trova quello che è stato trascurato.
+ Non si specializza — ti vede nella tua interezza.
+
+
inou permette alla tua IA di considerare tutto — ogni sezione, ogni marcatore, ogni variante — connette tutto e finalmente ti dà risposte che nessun altro poteva dare.
+
+
+
+
+
Perché abbiamo costruito questo
+
Hai raccolto anni di dati sanitari. Esami dall'ospedale. Analisi dal laboratorio. Risultati dal portale del medico. Dati dal tuo orologio. Forse anche il tuo DNA.
+
E poi c'è tutto quello che solo tu sai — il tuo peso, la tua pressione, la tua routine di allenamento, gli integratori che prendi, i sintomi che dimentichi sempre di menzionare.
+
È tutto lì — ma sparso in sistemi che non comunicano, con specialisti che vedono solo la loro parte, o chiuso nella tua testa.
+
Il tuo cardiologo non sa cosa ha trovato il tuo neurologo. Il tuo trainer non ha visto le tue analisi del sangue. Il tuo medico non ha idea di quali integratori prendi. E nessuno di loro ha tempo di sedersi con te e collegare i punti.
+
L'IA finalmente può. Può unire quello che nessun esperto da solo vede — e spiegartelo anche.
+
Ma questi dati non entrano in una finestra di chat. E l'ultima cosa che vuoi è la tua storia clinica sui server di qualcun altro, ad addestrare i loro modelli.
+
inou unisce tutto — laboratorio, imaging, genetica, parametri vitali, farmaci, integratori — crittografato, privato, e non condiviso con assolutamente nessuno. La tua IA si connette in sicurezza. I tuoi dati restano tuoi.
+
La tua salute, compresa.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_ja.tmpl b/templates/landing_ja.tmpl
new file mode 100644
index 0000000..70c4b72
--- /dev/null
+++ b/templates/landing_ja.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_ja"}}
+
+
+
+
+
inouはあなたの健康記録を整理し、AIと安全かつプライベートに共有します。
+
あなたの健康を、理解する。
+
{{if .Dossier}}
友達を招待{{else}}
ログイン{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+
健康管理にAIが必要な理由
+
+
あなたの健康データは何十もの場所に散らばっています — 循環器科、神経科、検査機関、スマートウォッチ、アプリ、23andMe。そして残りを知っているのはあなただけ:何を食べ、何を飲み、どんなサプリを取っているか。運動習慣。症状。目標 — 妊娠を目指しているのか、マラソンのトレーニング中なのか、単に疲れを減らしたいだけなのか。
+
健康を維持したい人も、難しい診断と向き合っている人も、自分で主張できない家族の世話をしている人も — どの医師も全体像を見ていません。すべてをつなぐシステムはありません。
+
でも、あなたはすべてにアクセスできます。足りないのは、それを理解する専門知識だけです。
+
あなたのAIにはそれがあります。inouは全体像を与えます。
+
+
+
+
+
+
課題
+
あなたのMRIには4,000枚のスライスがあります。
10分で読影されました。
+
あなたのゲノムには数百万の変異があります。
わかったのは目の色と祖先のルーツだけでした。
+
あなたの血液検査には数十のマーカーがあります。
医師は「問題ありません」と言いました。
+
あなたの時計は10,000時間の睡眠を記録しています。
トレーナーはその存在を知りません。
+
あなたは100種類のサプリメントを試しました。
誰も何を試したか聞きませんでした。
+
つながりはそこにあります。
ただ、一人の人間には複雑すぎるのです。
+
+ あなたの体がワーファリンをどう代謝するか、誰も知りません — あなた自身も。
+ でも答えは23andMeに隠れているかもしれません。
+ MRIの「異常なし」— 誰かが4,000枚すべてを本当に注意深く見ましたか?
+ 甲状腺は「基準値内」— でも誰もそれをあなたの疲労、体重、いつも寒いことと結びつけていません。
+
+
+ 誰も午後のコーヒーと睡眠の質を結びつけていません。
+ 鉄分レベルとトレーニング時の疲労を。
+ 遺伝子とブレインフォグを。
+
+
+ あなたのAIは忘れません。
+ 急ぎません。
+ 見落とされたものを見つけます。
+ 専門分野を持たず — あなたを全体として見ます。
+
+
inouはあなたのAIにすべてを考慮させます — すべてのスライス、すべてのマーカー、すべての変異 — すべてをつなぎ、他の誰も答えられなかった答えをついに提供します。
+
+
+
+
+
私たちがこれを作った理由
+
あなたは何年もの健康データを蓄積してきました。病院の検査。検査機関の結果。患者ポータルの記録。時計のデータ。DNAかもしれません。
+
そして、あなただけが知っていることがあります — 体重、血圧、トレーニングプログラム、飲んでいるサプリ、いつも言い忘れる症状。
+
すべてそこにあります — でも、互いに通信しないシステム、自分の専門分野しか見ない専門医、またはあなた自身の頭の中に閉じ込められています。
+
循環器科医は神経科医が何を見つけたか知りません。トレーナーは血液検査を見ていません。かかりつけ医はどんなサプリを飲んでいるか知りません。そして誰も、あなたと一緒に座って点をつなぐ時間がありません。
+
AIならついにできます。どの専門家単独では見えないものをまとめ — さらにそれを説明してくれます。
+
でも、このデータはチャットウィンドウに収まりません。そして最も避けたいのは、あなたの医療履歴が他人のサーバーに置かれ、そのモデルを訓練することです。
+
inouはすべてをまとめます — 検査、画像、遺伝子、バイタル、薬、サプリ — 暗号化され、プライベートで、誰とも共有されません。あなたのAIは安全に接続します。データはあなたのものです。
+
あなたの健康を、理解する。
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_ko.tmpl b/templates/landing_ko.tmpl
new file mode 100644
index 0000000..b49930b
--- /dev/null
+++ b/templates/landing_ko.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_ko"}}
+
+
+
+
+
inou는 당신의 건강 기록을 정리하고 AI와 안전하고 비공개로 공유합니다.
+
당신의 건강, 이해되다.
+
{{if .Dossier}}
친구 초대{{else}}
로그인{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+
건강을 위해 AI가 필요한 이유
+
+
당신의 건강 데이터는 수십 곳에 흩어져 있습니다 — 심장내과, 신경과, 검사실, 스마트워치, 앱, 23andMe. 그리고 나머지는 당신만 알고 있습니다: 무엇을 먹고, 무엇을 마시고, 어떤 보충제를 복용하는지. 운동 루틴. 증상. 목표 — 임신을 원하든, 마라톤 훈련 중이든, 단순히 덜 피곤해지고 싶든.
+
건강하고 그 상태를 유지하고 싶든, 어려운 진단을 받았든, 스스로를 대변할 수 없는 가족을 돌보든 — 어떤 의사도 전체 그림을 보지 못합니다. 모든 것을 연결하는 시스템은 없습니다.
+
하지만 당신은 모든 것에 접근할 수 있습니다. 부족한 것은 이해하는 전문 지식뿐입니다.
+
당신의 AI는 그것을 가지고 있습니다. inou는 전체 그림을 제공합니다.
+
+
+
+
+
+
도전
+
당신의 MRI에는 4,000개의 슬라이스가 있습니다.
10분 만에 판독되었습니다.
+
당신의 게놈에는 수백만 개의 변이가 있습니다.
알게 된 것은 눈 색깔과 조상의 출신지뿐이었습니다.
+
당신의 혈액 검사에는 수십 개의 마커가 있습니다.
의사는 "다 괜찮아 보입니다"라고 했습니다.
+
당신의 시계는 10,000시간의 수면을 기록했습니다.
트레이너는 그 존재를 모릅니다.
+
당신은 백 가지 다른 보충제를 시도했습니다.
아무도 어떤 것인지 묻지 않았습니다.
+
연결고리는 거기 있습니다.
한 사람이 다루기엔 너무 복잡할 뿐입니다.
+
+ 당신의 몸이 와파린을 어떻게 처리하는지 아무도 모릅니다 — 당신 자신도.
+ 하지만 답은 23andMe에 숨겨져 있을 수 있습니다.
+ MRI의 그 "이상 없음" — 누군가 정말로 4,000개 슬라이스를 모두 주의 깊게 봤을까요?
+ 갑상선은 "정상 범위" — 하지만 아무도 그것을 피로, 체중, 항상 추운 것과 연결하지 않았습니다.
+
+
+ 아무도 오후 커피와 수면의 질을 연결하지 않습니다.
+ 철분 수치와 운동 시 피로를.
+ 유전자와 브레인 포그를.
+
+
+ 당신의 AI는 잊지 않습니다.
+ 서두르지 않습니다.
+ 놓친 것을 찾습니다.
+ 전문화되지 않습니다 — 당신을 전체로 봅니다.
+
+
inou는 당신의 AI가 모든 것을 고려하게 합니다 — 모든 슬라이스, 모든 마커, 모든 변이 — 모든 것을 연결하고 마침내 다른 누구도 줄 수 없었던 답을 제공합니다.
+
+
+
+
+
우리가 이것을 만든 이유
+
당신은 수년간의 건강 데이터를 수집해 왔습니다. 병원 검사. 검사실 결과. 환자 포털의 기록. 시계의 데이터. 어쩌면 DNA까지.
+
그리고 당신만 아는 것들이 있습니다 — 체중, 혈압, 운동 프로그램, 복용하는 보충제, 항상 말하기를 잊는 증상들.
+
모든 것이 거기 있습니다 — 하지만 서로 소통하지 않는 시스템들, 자기 분야만 보는 전문의들, 또는 당신 머릿속에 갇혀 있습니다.
+
심장내과 의사는 신경과 의사가 무엇을 발견했는지 모릅니다. 트레이너는 혈액 검사를 보지 못했습니다. 주치의는 어떤 보충제를 복용하는지 모릅니다. 그리고 그들 중 누구도 당신과 함께 앉아서 점들을 연결할 시간이 없습니다.
+
AI는 마침내 할 수 있습니다. 어떤 전문가 혼자서도 볼 수 없는 것을 모으고 — 게다가 설명해 줍니다.
+
하지만 이 데이터는 채팅 창에 들어가지 않습니다. 그리고 가장 원하지 않는 것은 당신의 의료 기록이 다른 사람의 서버에서 그들의 모델을 훈련시키는 것입니다.
+
inou는 모든 것을 모읍니다 — 검사, 영상, 유전자, 활력징후, 약물, 보충제 — 암호화되고, 비공개이며, 누구와도 공유되지 않습니다. 당신의 AI는 안전하게 연결됩니다. 데이터는 당신의 것입니다.
+
당신의 건강, 이해되다.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_nl.tmpl b/templates/landing_nl.tmpl
new file mode 100644
index 0000000..8a74f50
--- /dev/null
+++ b/templates/landing_nl.tmpl
@@ -0,0 +1,343 @@
+{{define "landing_nl"}}
+
+
+
+
+
+
+
inou organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.
+
Je gezondheid, begrepen.
+
+
+
+
+
+
+
Je hebt AI nodig voor je gezondheid
+
+
+
Je gezondheidsgegevens liggen verspreid over tientallen plekken — bij je cardioloog, je neuroloog, het lab, je smartwatch, je apps, je 23andMe. En dan is er nog alles wat alleen jij weet: wat je eet, wat je drinkt, welke supplementen je slikt. Je trainingsschema. Je klachten. Je doelen — of je nu zwanger wilt worden, traint voor een marathon, of gewoon minder moe wilt zijn.
+
+
Of je nu gezond bent en dat wilt blijven, worstelt met een lastige diagnose, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het complete plaatje. Geen enkel systeem verbindt het.
+
+
Maar jij hebt toegang tot alles. Je mist alleen de expertise om er iets van te maken.
+
+
Je AI wel. inou geeft het het complete plaatje.
+
+
+
+
+
+
+
De uitdaging
+
+
Je MRI heeft 4.000 beelden.
+
Die werd in 10 minuten beoordeeld.
+
+
+
+
Je genoom heeft miljoenen varianten.
+
Je leerde alleen je oogkleur en waar je voorouders vandaan kwamen.
+
+
+
+
Je bloedonderzoek heeft tientallen markers.
+
Je arts zei "alles ziet er goed uit."
+
+
+
+
Je horloge heeft 10.000 uur slaap bijgehouden.
+
Je trainer weet niet dat het bestaat.
+
+
+
+
Je hebt honderd verschillende supplementen geprobeerd.
+
Niemand vroeg welke.
+
+
+
+ De verbanden zijn er.
+ Ze zijn alleen te complex voor één persoon om te overzien.
+
+
+
+ Niemand weet hoe jouw lichaam Warfarine verwerkt — jijzelf ook niet.
+ Maar het antwoord zit misschien al in je 23andMe.
+ Die 'geen bijzonderheden' op je MRI — heeft iemand echt alle 4.000 beelden bekeken?
+ Je schildklier is 'binnen de norm' — maar niemand legde het verband met je vermoeidheid, je gewicht, dat je het altijd koud hebt.
+
+
+
+ Niemand verbindt je middagkoffie aan je slaapkwaliteit.
+ Je ijzergehalte aan je sportvermoeidheid.
+ Je genetica aan je brain fog.
+
+
+
+ Je AI vergeet niet.
+ Haast niet.
+ Vindt wat gemist werd.
+ Specialiseert niet — ziet de complete jij.
+
+
+
inou laat je AI alles meewegen — elk beeld, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.
+
+
+
+
+
+
Waarom we dit bouwden
+
+
Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Uitslagen uit het patiëntenportaal. Data van je horloge. Misschien zelfs je DNA.
+
+
En dan is er nog alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je slikt, de klachten die je steeds vergeet te noemen.
+
+
Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, bij specialisten die alleen hun eigen stukje zien, of opgesloten in je eigen hoofd.
+
+
Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je huisarts heeft geen idee welke supplementen je slikt. En geen van hen heeft tijd om met je te zitten en de puzzel te leggen.
+
+
AI kan dat eindelijk. Het kan samenbrengen wat geen enkele expert ziet — en het je ook nog uitleggen.
+
+
Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, gebruikt om hun modellen te trainen.
+
+
inou brengt alles samen — labs, beeldvorming, genetica, vitals, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.
+
+
Je gezondheid, begrepen.
+
+
+
+
+
{{.T.data_yours}}
+
+
+ {{.T.never_training}}
+ {{.T.never_training_desc}}
+
+
+ {{.T.never_shared}}
+ {{.T.never_shared_desc}}
+
+
+ {{.T.encrypted}}
+ {{.T.encrypted_desc}}
+
+
+ {{.T.delete}}
+ {{.T.delete_desc}}
+
+
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/landing_no.tmpl b/templates/landing_no.tmpl
new file mode 100644
index 0000000..b989ceb
--- /dev/null
+++ b/templates/landing_no.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_no"}}
+
+
+
+
+
inou organiserer og deler helsedossieren din med din AI — sikkert og privat.
+
Din helse, forstått.
+
+
+
+
+
+
Du trenger AI for helsen din
+
+
Helsedataene dine er spredt på dusinvis av steder — hos kardiologen din, nevrologen din, labben, smartklokken din, appene dine, 23andMe. Og bare du kjenner resten: hva du spiser, hva du drikker, hvilke kosttilskudd du tar. Treningsrutinen din. Symptomene dine. Målene dine — enten du prøver å bli gravid, trener til et maraton, eller bare prøver å føle deg mindre sliten.
+
Enten du er frisk og vil forbli det, navigerer en vanskelig diagnose, eller tar vare på et familiemedlem som ikke kan tale for seg selv — ingen enkelt lege ser hele bildet. Ingen system kobler alt sammen.
+
Men du har tilgang til alt. Du mangler bare ekspertisen til å forstå det.
+
Din AI har den. inou gir den hele bildet.
+
+
+
+
+
+
Utfordringen
+
MR-en din har 4 000 snitt.
Den ble lest på 10 minutter.
+
Genomet ditt har millioner av varianter.
Du fikk bare vite øyenfargen din og hvor forfedrene dine kom fra.
+
Blodprøvene dine har dusinvis av markører.
Legen din sa "alt ser bra ut."
+
Klokken din har registrert 10 000 timer søvn.
Treneren din vet ikke at den finnes.
+
Du har prøvd hundre forskjellige kosttilskudd.
Ingen spurte hvilke.
+
Koblingene er der.
De er bare for komplekse for én person.
+
+ Ingen vet hvordan kroppen din prosesserer Warfarin — ikke engang du.
+ Men svaret kan ligge gjemt i 23andMe.
+ Det "uten anmerkning" på MR-en din — så noen virkelig nøye på alle 4 000 snittene?
+ Skjoldbruskkjertelen din er "innenfor normalverdier" — men ingen koblet det til trettheten din, vekten din, at du alltid fryser.
+
+
+ Ingen kobler ettermiddagskaffen din til søvnkvaliteten din.
+ Jernverdiene dine til trettheten din under trening.
+ Genetikken din til hjernetåken din.
+
+
+ Din AI glemmer ikke.
+ Stresser ikke.
+ Finner det som ble oversett.
+ Spesialiserer seg ikke — ser deg som helhet.
+
+
inou lar din AI ta hensyn til alt — hvert snitt, hver markør, hver variant — kobler alt sammen og gir deg endelig svar som ingen andre kunne gi.
+
+
+
+
+
Hvorfor vi bygde dette
+
Du har samlet år med helsedata. Undersøkelser fra sykehuset. Prøver fra labben. Resultater fra pasientportalen. Data fra klokken din. Kanskje til og med DNA-et ditt.
+
Og så er det alt som bare du vet — vekten din, blodtrykket ditt, treningsprogrammet ditt, tilskuddene du tar, symptomene du alltid glemmer å nevne.
+
Alt er der — men spredt i systemer som ikke snakker sammen, hos spesialister som bare ser sin del, eller låst inne i ditt eget hode.
+
Kardiologen din vet ikke hva nevrologen din fant. Treneren din har ikke sett blodprøvene dine. Legen din aner ikke hvilke tilskudd du tar. Og ingen av dem har tid til å sette seg ned med deg og koble prikkene.
+
AI kan endelig gjøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for deg i tillegg.
+
Men disse dataene får ikke plass i et chatvindu. Og det siste du vil er sykehistorikken din på andres servere, som trener modellene deres.
+
inou samler alt — lab, bildediagnostikk, genetikk, vitale tegn, medisiner, tilskudd — kryptert, privat, og delt med absolutt ingen. Din AI kobler seg til sikkert. Dataene dine forblir dine.
+
Din helse, forstått.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_pt.tmpl b/templates/landing_pt.tmpl
new file mode 100644
index 0000000..6eb4923
--- /dev/null
+++ b/templates/landing_pt.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_pt"}}
+
+
+
+
+
inou organiza e compartilha seu dossiê de saúde com sua IA — de forma segura e privada.
+
Sua saúde, compreendida.
+
+
+
+
+
+
Você precisa de IA para sua saúde
+
+
Seus dados de saúde estão espalhados em dezenas de lugares — com seu cardiologista, seu neurologista, o laboratório, seu relógio inteligente, seus apps, seu 23andMe. E só você sabe o resto: o que você come, o que bebe, quais suplementos toma. Sua rotina de exercícios. Seus sintomas. Seus objetivos — seja tentando engravidar, treinando para uma maratona, ou apenas tentando se sentir menos cansado.
+
Seja você saudável e querendo continuar assim, navegando um diagnóstico difícil, ou cuidando de um familiar que não pode se defender sozinho — nenhum médico vê o quadro completo. Nenhum sistema conecta tudo.
+
Mas você tem acesso a tudo. Só falta a expertise para entender tudo.
+
Sua IA tem. inou dá a ela o quadro completo.
+
+
+
+
+
+
O desafio
+
Sua ressonância tem 4.000 cortes.
Foi lida em 10 minutos.
+
Seu genoma tem milhões de variantes.
Você só descobriu a cor dos seus olhos e de onde vieram seus ancestrais.
+
Seu exame de sangue tem dezenas de marcadores.
Seu médico disse "está tudo bem."
+
Seu relógio registrou 10.000 horas de sono.
Seu treinador não sabe que ele existe.
+
Você já tentou cem suplementos diferentes.
Ninguém perguntou quais.
+
As conexões estão lá.
São complexas demais para uma pessoa só.
+
+ Ninguém sabe como seu corpo processa a Varfarina — nem você.
+ Mas a resposta pode estar escondida no seu 23andMe.
+ Aquele "sem alterações" na sua ressonância — alguém realmente olhou os 4.000 cortes com atenção?
+ Sua tireoide está "dentro do normal" — mas ninguém conectou com sua fadiga, seu peso, você sempre sentir frio.
+
+
+ Ninguém conecta seu café da tarde com sua qualidade de sono.
+ Seus níveis de ferro com sua fadiga no treino.
+ Sua genética com sua névoa mental.
+
+
+ Sua IA não esquece.
+ Não tem pressa.
+ Encontra o que foi perdido.
+ Não se especializa — vê você por inteiro.
+
+
inou permite que sua IA considere tudo — cada corte, cada marcador, cada variante — conecta tudo e finalmente te dá respostas que ninguém mais conseguia dar.
+
+
+
+
+
Por que construímos isso
+
Você coletou anos de dados de saúde. Exames do hospital. Análises do laboratório. Resultados do portal do médico. Dados do seu relógio. Talvez até seu DNA.
+
E depois tem tudo que só você sabe — seu peso, sua pressão, sua rotina de treino, os suplementos que você toma, os sintomas que você sempre esquece de mencionar.
+
Está tudo lá — mas espalhado em sistemas que não conversam, com especialistas que só veem sua parte, ou preso na sua própria cabeça.
+
Seu cardiologista não sabe o que seu neurologista encontrou. Seu treinador não viu seus exames de sangue. Seu médico não faz ideia de quais suplementos você toma. E nenhum deles tem tempo para sentar com você e conectar os pontos.
+
A IA finalmente pode. Ela pode unir o que nenhum especialista sozinho vê — e ainda explicar para você.
+
Mas esses dados não cabem numa janela de chat. E a última coisa que você quer é seu histórico médico nos servidores de outra pessoa, treinando os modelos deles.
+
inou une tudo — laboratório, imagens, genética, sinais vitais, medicamentos, suplementos — criptografado, privado, e sem compartilhar com absolutamente ninguém. Sua IA se conecta com segurança. Seus dados continuam sendo seus.
+
Sua saúde, compreendida.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_ru.tmpl b/templates/landing_ru.tmpl
new file mode 100644
index 0000000..5258d43
--- /dev/null
+++ b/templates/landing_ru.tmpl
@@ -0,0 +1,343 @@
+{{define "landing_ru"}}
+
+
+
+
+
+
+
inou организует и передаёт ваше медицинское досье вашему ИИ — безопасно и конфиденциально.
+
Ваше здоровье — понятно.
+
+
+
+
+
+
+
Вам нужен ИИ для вашего здоровья
+
+
+
Ваши медицинские данные разбросаны по десяткам мест — у кардиолога, невролога, в лаборатории, в умных часах, приложениях, в 23andMe. И только вы знаете остальное: что вы едите, что пьёте, какие добавки принимаете. Ваш режим тренировок. Ваши симптомы. Ваши цели — хотите ли вы забеременеть, готовитесь к марафону или просто хотите меньше уставать.
+
+
Здоровы ли вы и хотите таким остаться, справляетесь со сложным диагнозом или заботитесь о близком, который не может сам за себя постоять — ни один врач не видит полную картину. Ни одна система не связывает всё воедино.
+
+
Но у вас есть доступ ко всему. Вам просто не хватает экспертизы, чтобы во всём разобраться.
+
+
У вашего ИИ она есть. inou даёт ему полную картину.
+
+
+
+
+
+
+
Проблема
+
+
В вашем МРТ 4 000 снимков.
+
Его прочитали за 10 минут.
+
+
+
+
В вашем геноме миллионы вариантов.
+
Вы узнали только цвет глаз и откуда ваши предки.
+
+
+
+
В вашем анализе крови десятки показателей.
+
Врач сказал «всё в норме».
+
+
+
+
Ваши часы отследили 10 000 часов сна.
+
Ваш тренер не знает, что они существуют.
+
+
+
+
Вы перепробовали сотню разных добавок.
+
Никто не спросил какие.
+
+
+
+ Связи есть.
+ Они просто слишком сложны для одного человека.
+
+
+
+ Никто не знает, как ваш организм усваивает Варфарин — даже вы сами.
+ Но ответ, возможно, уже есть в вашем 23andMe.
+ Та «норма» в вашем МРТ — кто-нибудь внимательно посмотрел все 4 000 снимков?
+ Ваша щитовидка «в пределах нормы» — но никто не связал это с усталостью, весом, тем что вам всегда холодно.
+
+
+
+ Никто не связывает ваш послеобеденный кофе с качеством сна.
+ Уровень железа с усталостью на тренировках.
+ Вашу генетику с туманом в голове.
+
+
+
+ Ваш ИИ не забывает.
+ Не торопится.
+ Находит упущенное.
+ Не специализируется — видит вас целиком.
+
+
+
inou позволяет вашему ИИ учесть всё — каждый снимок, каждый показатель, каждый вариант — связать всё воедино и наконец дать ответы, которые никто другой дать не мог.
+
+
+
+
+
+
Почему мы это создали
+
+
Вы годами собирали медицинские данные. Снимки из больницы. Анализы из лаборатории. Результаты с портала врача. Данные с часов. Может быть, даже ДНК.
+
+
И ещё всё то, что знаете только вы — ваш вес, давление, график тренировок, добавки, симптомы, о которых всё забываете упомянуть.
+
+
Всё это есть — но разбросано по системам, которые не общаются друг с другом, у специалистов, которые видят только свой кусочек, или заперто у вас в голове.
+
+
Ваш кардиолог не знает, что нашёл невролог. Ваш тренер не видел анализы крови. Ваш врач понятия не имеет, какие добавки вы принимаете. И ни у кого из них нет времени сесть с вами и собрать пазл.
+
+
ИИ наконец может. Он способен собрать воедино то, чего не видит ни один эксперт — и ещё и объяснить вам.
+
+
Но эти данные не помещаются в окно чата. И меньше всего вам нужно, чтобы ваша медицинская история оказалась на чужих серверах, обучая чужие модели.
+
+
inou собирает всё вместе — анализы, снимки, генетику, показатели, лекарства, добавки — зашифровано, конфиденциально, ни с кем не делится. Ваш ИИ подключается безопасно. Ваши данные остаются вашими.
+
+
Ваше здоровье — понятно.
+
+
+
+
+
{{.T.data_yours}}
+
+
+ {{.T.never_training}}
+ {{.T.never_training_desc}}
+
+
+ {{.T.never_shared}}
+ {{.T.never_shared_desc}}
+
+
+ {{.T.encrypted}}
+ {{.T.encrypted_desc}}
+
+
+ {{.T.delete}}
+ {{.T.delete_desc}}
+
+
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/landing_sv.tmpl b/templates/landing_sv.tmpl
new file mode 100644
index 0000000..ddf5908
--- /dev/null
+++ b/templates/landing_sv.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_sv"}}
+
+
+
+
+
inou organiserar och delar din hälsodossier med din AI — säkert och privat.
+
Din hälsa, förstådd.
+
+
+
+
+
+
Du behöver AI för din hälsa
+
+
Dina hälsodata finns utspridda på dussintals ställen — hos din kardiolog, din neurolog, labbet, din smartklocka, dina appar, ditt 23andMe. Och bara du vet resten: vad du äter, vad du dricker, vilka kosttillskott du tar. Din träningsrutin. Dina symtom. Dina mål — oavsett om du försöker bli gravid, tränar för ett maraton, eller bara försöker känna dig mindre trött.
+
Oavsett om du är frisk och vill förbli det, navigerar en svår diagnos, eller tar hand om en familjemedlem som inte kan föra sin egen talan — ingen läkare ser hela bilden. Inget system kopplar ihop allt.
+
Men du har tillgång till allt. Du saknar bara expertisen att förstå allt.
+
Din AI har den. inou ger den hela bilden.
+
+
+
+
+
+
Utmaningen
+
Din MR har 4 000 snitt.
Den lästes på 10 minuter.
+
Ditt genom har miljontals varianter.
Du fick bara veta din ögonfärg och var dina förfäder kom ifrån.
+
Ditt blodprov har dussintals markörer.
Din läkare sa "allt ser bra ut."
+
Din klocka har registrerat 10 000 timmars sömn.
Din tränare vet inte att den finns.
+
Du har provat hundra olika kosttillskott.
Ingen frågade vilka.
+
Kopplingarna finns där.
De är bara för komplexa för en enda person.
+
+ Ingen vet hur din kropp metaboliserar Warfarin — inte ens du.
+ Men svaret kan redan vara gömt i ditt 23andMe.
+ Det där "utan anmärkning" på din MR — tittade någon verkligen noggrant på alla 4 000 snitt?
+ Din sköldkörtel är "inom normalvärden" — men ingen kopplade det till din trötthet, din vikt, att du alltid fryser.
+
+
+ Ingen kopplar ditt eftermiddagskaffe till din sömnkvalitet.
+ Dina järnnivåer till din trötthet vid träning.
+ Din genetik till din hjärndimma.
+
+
+ Din AI glömmer inte.
+ Stressar inte.
+ Hittar det som missades.
+ Specialiserar sig inte — ser dig som helhet.
+
+
inou låter din AI ta hänsyn till allt — varje snitt, varje markör, varje variant — kopplar ihop allt och ger dig äntligen svar som ingen annan kunde ge.
+
+
+
+
+
Varför vi byggde detta
+
Du har samlat år av hälsodata. Undersökningar från sjukhuset. Prover från labbet. Resultat från patientportalen. Data från din klocka. Kanske till och med ditt DNA.
+
Och sedan finns allt som bara du vet — din vikt, ditt blodtryck, din träningsrutin, kosttillskotten du tar, symtomen du alltid glömmer nämna.
+
Allt finns där — men utspritt i system som inte pratar med varandra, hos specialister som bara ser sin del, eller låst i ditt eget huvud.
+
Din kardiolog vet inte vad din neurolog hittade. Din tränare har inte sett dina blodprover. Din läkare har ingen aning om vilka kosttillskott du tar. Och ingen av dem har tid att sitta ner med dig och koppla ihop punkterna.
+
AI kan äntligen göra det. Den kan samla det som ingen enskild expert ser — och dessutom förklara det för dig.
+
Men dessa data får inte plats i ett chattfönster. Och det sista du vill är din sjukdomshistorik på någon annans servrar, för att träna deras modeller.
+
inou samlar allt — labb, bilddiagnostik, genetik, vitalparametrar, mediciner, kosttillskott — krypterat, privat, och inte delat med absolut någon. Din AI ansluter säkert. Dina data förblir dina.
+
Din hälsa, förstådd.
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/landing_zh.tmpl b/templates/landing_zh.tmpl
new file mode 100644
index 0000000..238ad5a
--- /dev/null
+++ b/templates/landing_zh.tmpl
@@ -0,0 +1,121 @@
+{{define "landing_zh"}}
+
+
+
+
+
inou整理并与您的AI安全私密地共享您的健康档案。
+
您的健康,被理解。
+
{{if .Dossier}}
邀请朋友{{else}}
登录{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+
您需要AI来管理健康
+
+
您的健康数据分散在数十个地方——心内科、神经科、检验科、智能手表、应用程序、23andMe。只有您知道其余的:吃什么、喝什么、服用什么补充剂。您的运动习惯。您的症状。您的目标——无论是想怀孕、为马拉松训练,还是只想少些疲惫。
+
无论您是健康并想保持健康,正在应对困难的诊断,还是在照顾无法为自己发声的家人——没有一位医生能看到全貌。没有系统能连接一切。
+
但您可以获取所有信息。您只是缺乏理解它的专业知识。
+
您的AI有这个能力。inou给它完整的画面。
+
+
+
+
+
+
挑战
+
您的MRI有4000张切片。
10分钟内被阅读完毕。
+
您的基因组有数百万个变异。
您只知道了眼睛颜色和祖先来源。
+
+
您的手表记录了10000小时的睡眠。
您的教练不知道它的存在。
+
+
关联就在那里。
只是对一个人来说太复杂了。
+
+ 没人知道您的身体如何代谢华法林——包括您自己。
+ 但答案可能就藏在您的23andMe里。
+ MRI上那个未见异常——真的有人仔细看过所有4000张切片吗?
+ 您的甲状腺在正常范围内——但没人把它与您的疲劳、体重、总是怕冷联系起来。
+
+
+ 没人把您的下午咖啡与睡眠质量联系起来。
+ 您的铁含量与训练疲劳。
+ 您的基因与脑雾。
+
+
+ 您的AI不会忘记。
+ 不会匆忙。
+ 找到被遗漏的。
+ 不专科——整体地看待您。
+
+
inou让您的AI考虑一切——每张切片、每个指标、每个变异——将所有连接起来,终于给您其他人无法给出的答案。
+
+
+
+
+
我们为什么创建这个
+
您收集了多年的健康数据。医院的检查。检验科的结果。患者门户的记录。手表的数据。也许还有您的DNA。
+
还有只有您知道的一切——您的体重、血压、训练计划、服用的补充剂、总是忘记提到的症状。
+
一切都在那里——但分散在互不沟通的系统中、只看自己领域的专科医生那里,或者锁在您自己的脑海中。
+
心内科医生不知道神经科医生发现了什么。您的教练没看过您的血液检查。您的医生不知道您服用什么补充剂。他们中没有一个人有时间坐下来与您一起连接这些点。
+
AI终于可以做到。它可以汇集任何单一专家都看不到的——还能向您解释。
+
但这些数据放不进聊天窗口。您最不想要的是您的病历在别人的服务器上,训练他们的模型。
+
inou将一切汇集——检验、影像、基因、生命体征、药物、补充剂——加密、私密,不与任何人共享。您的AI安全连接。数据仍然是您的。
+
您的健康,被理解。
+
+
+
+
{{.T.data_yours}}
+
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+
+{{end}}
diff --git a/templates/login.tmpl b/templates/login.tmpl
new file mode 100644
index 0000000..e264416
--- /dev/null
+++ b/templates/login.tmpl
@@ -0,0 +1,32 @@
+{{define "login"}}
+
+
+
+
+
inou health
Sign in
+
Enter your email to sign in
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+ We'll send you a verification code.
No password needed.
+
+
+
+
+ {{template "footer"}}
+
+
+
+{{end}}
diff --git a/templates/minor_error.tmpl b/templates/minor_error.tmpl
new file mode 100644
index 0000000..e8e167f
--- /dev/null
+++ b/templates/minor_error.tmpl
@@ -0,0 +1,14 @@
+{{define "minor_error"}}
+
+
{{.T.must_be_18}}
+
+
+
{{.T.minor_explanation}}
+
{{.T.minor_next_steps}}
+
+
+
← {{.T.use_different_dob}}
+
+ {{template "footer"}}
+
+{{end}}
diff --git a/templates/onboard.tmpl b/templates/onboard.tmpl
new file mode 100644
index 0000000..5dcd805
--- /dev/null
+++ b/templates/onboard.tmpl
@@ -0,0 +1,60 @@
+{{define "onboard"}}
+
+
+
+
+
inou health
+
{{.T.create_dossier}}
+
{{.T.tell_us}}
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+
+ {{template "footer"}}
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/templates/permissions.tmpl b/templates/permissions.tmpl
new file mode 100644
index 0000000..c67ad06
--- /dev/null
+++ b/templates/permissions.tmpl
@@ -0,0 +1,150 @@
+{{define "permissions"}}
+
+
+
+
+
+
+
{{.T.permissions_title}}
+
{{.T.permissions_subtitle}}
+
+
← {{.T.back}}
+
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+ {{if .Success}}
+
{{.Success}}
+ {{end}}
+
+
+
+
{{.T.current_access}}
+
+
+ {{if .Grantees}}
+ {{range .Grantees}}
+
+
+ {{.Name}}
+ {{.Role}}
+ {{.Ops}}
+
+
+
+ {{end}}
+ {{else}}
+
{{.T.no_grantees}}
+ {{end}}
+
+
+
+
+
+
{{.T.grant_access}}
+
+
+
+
+
+
+
{{.T.role_descriptions}}
+
+ {{range .Roles}}
+
+
+ {{.Name}}
+ {{.Ops}}
+
+
{{.Description}}
+
+ {{end}}
+
+
+
+
{{.T.ops_legend}}
+
+ r = {{.T.op_read_desc}} ·
+ w = {{.T.op_write_desc}} ·
+ d = {{.T.op_delete_desc}} ·
+ m = {{.T.op_manage_desc}}
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/templates/pricing.tmpl b/templates/pricing.tmpl
new file mode 100644
index 0000000..7638361
--- /dev/null
+++ b/templates/pricing.tmpl
@@ -0,0 +1,274 @@
+{{define "pricing"}}
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ | Health Data |
+
+
+ | Vitals (BP, HR, weight, temp) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Symptoms & conditions |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Medications |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Exercise & activity |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Family history |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Lab results |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+ | Consumer genome (23andMe) |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+ | Medical imaging (MRI, CT, X-ray) |
+ ✗ |
+ ✗ |
+ ✓ |
+
+
+ | Clinical genome sequencing |
+ ✗ |
+ ✗ |
+ ✓ |
+
+
+
+ | AI Features |
+
+
+ | MCP integration (Claude, ChatGPT) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Personalized AI answers |
+ Limited |
+ ✓ |
+ ✓ |
+
+
+ | Health trend analysis |
+ ✗ |
+ ✓ |
+ ✓ |
+
+
+
+ | Storage & Access |
+
+
+ | Multi-dossier support (family) |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | FIPS 140-3 encryption |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+ | Data export |
+ ✓ |
+ ✓ |
+ ✓ |
+
+
+
+
+
+ {{template "footer"}}
+
+{{end}}
diff --git a/templates/privacy.tmpl b/templates/privacy.tmpl
new file mode 100644
index 0000000..6d17423
--- /dev/null
+++ b/templates/privacy.tmpl
@@ -0,0 +1,232 @@
+{{define "privacy"}}
+
+
+
+
+
+
Your data. Your rules.
+
We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.
+
+
+
+
What we collect
+
+
Account information.
+
Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.
+
+
Medical files you upload.
+
DICOM images (MRI, CT, X-ray), lab results, genetic data, and any other health information you choose to share — photos, measurements, symptoms, or anything else you want to track or discuss with your AI.
+
+
Security logs.
+
IP addresses, for security purposes only. We do not collect physical addresses or phone numbers. Payment is handled by third-party processors — we never see your card details.
+
+
+
+
How we use it
+
Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.
+
+
+
+
What we promise
+
+
We never share your data.
+
Not with advertisers. Not with partners. Not with anyone. We will comply with lawful requests from authorities (such as court orders or subpoenas), but nothing else. In the event of a company acquisition, your data would not be sold — it would either transfer under the same privacy terms or be deleted.
+
+
We never train AI on your data.
+
Your scans, your labs, your DNA — none of it feeds any model. Period.
+
+
We never sell your data.
+
There is no business model that involves your information. You are the customer, not the product.
+
+
We never track you.
+
No Google Analytics. No Meta pixels. No tracking scripts. We have no idea what you click, where you came from, or where you go next.
+
+
We never look at your data.
+
Access requires your explicit request, is restricted to senior staff, and is logged in both your audit trail and ours.
+
+
One cookie.
+
We use one cookie to keep you logged in. Your language preference is stored in your account. No tracking, no analytics, no third parties.
+
+
+
+
How we protect it
+
+
HIPAA-grade security.
+
HIPAA is the US law that governs how medical records must be protected. We follow those same standards.
+
+
FIPS 140-3 encryption.
+
FIPS 140-3 is the US government standard for cryptographic security. Your files are encrypted using FIPS 140-3 validated cryptography — tested, audited, and certified by independent labs.
+
+
Independent infrastructure.
+
We don't run on Big Tech clouds. No Google. No Amazon. No Microsoft. Data is stored on servers in the United States. If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.
+
+
+
+
What you control
+
+
See everything.
+
Request a full export of everything we store — in a format you can actually use.
+
+
Fix anything.
+
Found a mistake? You can correct it yourself, or ask us to help.
+
+
Delete everything.
+
One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster — we do not offer restores of individual accounts or deleted data.
+
+
Take it with you.
+
Want to move to another service? We'll export your data in standard formats. You're never locked in.
+
+
Change your mind.
+
Gave us permission for something? Revoke it anytime. We stop immediately.
+
+
+
+
About your AI
+
+
When you connect your AI to inou, your data travels through an encrypted bridge directly to your AI session.
+
+
What we control: keeping your data encrypted, secure, and private on our side.
+
+
What we can't control: what happens once your AI processes it. Each AI provider has their own privacy policy. We encourage you to read it.
+
+
We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.
+
+
+
+
Children's privacy
+
inou is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.
+
+
+
+
The legal stuff
+
We comply with FADP (Swiss data protection), GDPR (European data protection), and HIPAA (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.
+
We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.
+
Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.
+
Questions, concerns, or requests: privacy@inou.com
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/prompts.tmpl b/templates/prompts.tmpl
new file mode 100644
index 0000000..555208b
--- /dev/null
+++ b/templates/prompts.tmpl
@@ -0,0 +1,760 @@
+{{define "prompts"}}
+
+
{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in
+
Track daily measurements and observations
+
+ {{if .Error}}
{{.Error}}
{{end}}
+ {{if .Success}}
{{.Success}}
{{end}}
+
+ {{if .Prompts}}
+
+
+
+
+
+ {{range .Entries}}
+
+
✕
+
+
+ {{range .Fields}}
+
+ {{if eq .Type "number"}}
+ {{.Value}}
+ {{if .Unit}}{{.Unit}}{{end}}
+ {{else if eq .Type "checkbox"}}
+ {{if .Value}}✓{{else}}—{{end}}
+ {{if .Label}}{{.Label}}{{end}}
+ {{else}}
+ {{.Value}}
+ {{end}}
+
+ {{end}}
+
+
+ {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}}
+
+ {{end}}
+ {{range .Prompts}}
+ {{$prompt := .}}
+
+
+
✕
+
+
Stop tracking?
+
Yes
+
No
+
+
+
+
+
+
+ {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}}
+
+
+
+
+
+ {{end}}
+
+
+ {{else}}
+
+ {{end}}
+
+
+
+ {{template "footer"}}
+
+
+
+
+
+{{end}}
diff --git a/templates/security.tmpl b/templates/security.tmpl
new file mode 100644
index 0000000..330437f
--- /dev/null
+++ b/templates/security.tmpl
@@ -0,0 +1,180 @@
+{{define "security"}}
+
+
+
+
+
+
How we protect your health dossier.
+
Security isn't a feature we added. It's how we built inou from day one.
+
+
+
+
Your data never shares a server.
+
Most services run on shared cloud infrastructure — your files sitting next to thousands of strangers. Not here. inou runs on dedicated, single-tenant hardware. Your data lives on machines that exist solely for this purpose.
+
+
+
+
Encryption you can trust.
+
FIPS 140-3 is the US government standard for cryptographic security — the same bar the military uses. Your files are encrypted in flight with TLS 1.3, encrypted again at the application layer before they touch the database, and stay encrypted at rest. Three layers deep.
+
+
+
+
Power doesn't go out.
+
Servers run on uninterruptible power, backed by a natural gas generator. Not a battery that buys you fifteen minutes — a generator with fuel supply independent of the grid. If the power company fails, we don't.
+
+
+
+
Drives fail. Data doesn't.
+
Storage runs on ZFS with RAID-Z2 — enterprise technology that survives the simultaneous failure of any two drives without losing a byte. Backups happen automatically. (Our founder spent two decades building backup systems for a living. We take this seriously.)
+
+
+
+
The internet has a backup too.
+
Primary connectivity is dedicated fiber. If that fails, satellite kicks in. Terrestrial and space-based redundancy — because your access matters.
+
+
+
+
We watch. We act.
+
Continuous uptime monitoring, automated alerting, 24/7. If something blinks wrong, we know — and our systems respond before you'd ever notice.
+
+
+
+
We keep attackers out.
+
Firewall rules block malicious traffic at the edge. Tarpits slow down scanners and bots, wasting their time instead of ours. Role-based access control ensures every request is authenticated and authorized — no exceptions.
+
+
+
+
Built with intention.
+
Most software is assembled from open source libraries — code written by strangers, maintained by volunteers, used by millions. When a vulnerability is discovered, every application using that code is at risk.
+
We made a different choice. inou is built entirely from proprietary code. We wrote every line ourselves. No third-party frameworks, no borrowed components, no dependency trees stretching into code we've never reviewed.
+
This means we know exactly what's running and exactly what's exposed. A minimal risk surface — not because we added security on top, but because we designed it that way from the beginning.
+
+
+
+
Physical security.
+
Hardware is housed in secured, access-controlled enclosures. Entry restricted to authorized inou personnel only. Climate-controlled, generator-backed, sited above regional flood levels.
+
+
+ {{template "footer"}}
+
+
+{{end}}
diff --git a/templates/share.tmpl b/templates/share.tmpl
new file mode 100644
index 0000000..7b464f3
--- /dev/null
+++ b/templates/share.tmpl
@@ -0,0 +1,60 @@
+{{define "share"}}
+
+
+
+
+
{{.T.share_access}}
+
{{.T.share_access_intro}}
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+
+ {{template "footer"}}
+
+{{end}}
diff --git a/templates/styleguide.tmpl b/templates/styleguide.tmpl
new file mode 100644
index 0000000..64b8a8b
--- /dev/null
+++ b/templates/styleguide.tmpl
@@ -0,0 +1,496 @@
+{{define "styleguide"}}
+
+
+
+
Style Guide
+
Design system components for inou
+
+
+
+
+
+
Your data. Your rules.
+
+ We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.
+
+
What we collect
+
Account information.
+
+ Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.
+
+
+
+
+
+
+
+
+
+
Section Title
1.4rem / 600
+
Subsection Title
1.1rem / 600
+
LABEL / CATEGORY
0.75rem / 600 / caps
+
Intro text — larger, lighter
1.15rem / 300
+
Body light — long-form
1rem / 300
+
Body regular — UI labels
1rem / 400
+
Mono: 1,234,567.89
SF Mono
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ default
+ care
+ COMING SOON
+ processing
+
+
+
+
+
+
+
+
Error message — something went wrong.
+
Info message — here's some useful information.
+
Success message — operation completed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Primary AI Assistant
+
Used for "Ask AI" prompts and analysis
+
+
+
+
+
+
+
+
+
+
Units
+
Measurement system for vitals
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Johan Jongsma
+
you
+
Born: 1985-03-15 · Male
+
📷 3 studies🧪 12 labs🧬 genome
+
+
+
+
Sophia
+
my role: Parent · care
+
Born: 2017-01-01 · Female
+
📷 16 studies🧪 0 labs
+
+
+
+Add dossier
+
+
+
+
+
+
+
+
+
+
+MRI BRAIN W/WO CONTRAST
+
13 series5/5/2022
+
+
+
+
+
+
+
+
+
+
+
+
+Complete Blood Count (CBC)
+
8 tests12/15/2024
+
+
+
Hemoglobin14.2 g/dL12.0–16.0
+
White Blood Cells7.8 K/µL4.5–11.0
+
+
+
+
+
+
+
+
+
+
+
+Medication Response
+
47 variants
+
+
+
+
+
+
CYP2C19rs4244285
+
G;Aintermediate
+
+
Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.
+
+
+
+
Show all 47 variants in Medication Response →
+
+
+
+Cardiovascular
18 variants
+
+
+
+
+
+
+
+
+
+
+
+
Today, 8:30 AM37.2 °C
+
Yesterday, 8:15 AM36.8 °C
+
Dec 24, 7:45 AM37.0 °C
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dec 20, 3:45 PM
+ Jim fell on his knee at soccer practice. Swelling visible, applied ice.
+
+
+ Dec 22, 10:20 AM
+ Swelling reduced. Still some bruising. Can walk without pain.
+
+
+ Dec 26, 9:15 AM
+ Almost fully healed. Light bruise remaining.
+
+
+
+
+
+
+
+
+
+📝
Mild headache after workout Dec 25
+
+
+
+
+
+
+
+
+
+ Vitamin D3
+ 1 capsule
+ · 5000 IU
+
+
morning, with food
+
+
+
+ Omega-3 Fish Oil
+ 2 capsules
+ · 2000 mg EPA/DHA
+
+
morning, with food
+
+
+
+ Magnesium Glycinate
+ 2 capsules
+ · 400 mg
+
+
evening
+
+
+
+ Liquid B12
+ 5 ml
+ · 1000 mcg
+
+
morning
+
+
+
+
+
+
+
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+
until Jan 23, 2025
+
+
active
+
+
+
+
TB-5002.5 mg subQ · 2x weekly
+
until Feb 5, 2025
+
+
active
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+
Aug 15 – Sep 7, 2025
+
+
completed
+
+
+
+
+
+
+
+
+
+
+
Click or drag files here
+
DICOM, PDF, CSV, VCF, and more
+
+
+
+
+
+
+
+ {{template "footer"}}
+
+
+
+
+
+
+
Ask AI about CYP2C19
+
I have a genetic variant in CYP2C19 (rs4244285) with genotype G;A.
+
+This makes me an intermediate metabolizer.
+
+What medications are affected by this? What should I discuss with my doctor?
+
+
+
+
+
+
+
+{{end}}
diff --git a/templates/upload.tmpl b/templates/upload.tmpl
new file mode 100644
index 0000000..87dec91
--- /dev/null
+++ b/templates/upload.tmpl
@@ -0,0 +1,187 @@
+{{define "upload"}}
+
+
← Back to {{.TargetDossier.Name}}
+
+
Upload health data
+
Files are automatically deleted after 7 days
+
+
+
+
+
+
+
+
+
Click or drag files here
+
DICOM, PDF, CSV, VCF, and more
+
+
+
+ {{if .UploadList}}
+
Recent uploads
+
+ {{range .UploadList}}
+
+
+ {{.FileName}}
+
+ {{if and (not .Deleted) (eq .Status "uploaded")}}
+
+ {{else}}
+ {{.Category}}
+ {{end}}
+ · {{.SizeHuman}} · {{.UploadedAt}}
+
+
+
+ {{if .Deleted}}
+ {{.DeletedReason}}
+ {{else}}
+ {{if ne .Status "uploaded"}}{{.Status}}{{end}}
+ Expires {{.ExpiresAt}}
+
+ {{end}}
+
+
+ {{end}}
+
+ {{else}}
+
+ No files uploaded yet
+
+ {{end}}
+
+
+
+
+
+{{end}}
diff --git a/templates/verify.tmpl b/templates/verify.tmpl
new file mode 100644
index 0000000..d3cda49
--- /dev/null
+++ b/templates/verify.tmpl
@@ -0,0 +1,33 @@
+{{define "verify"}}
+
+
+
+
+
inou health
+
{{.T.check_email}}
+
{{.T.code_sent_to}}
{{.Email}}
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+ {{.T.use_different_email}}
+
+
+
+
+ {{template "footer"}}
+
+
+{{end}}