Coverage for integrations / agent_engine / native_onboarding.py: 0.0%
256 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1#!/usr/bin/env python3
2"""
3HART OS Native Onboarding — "Light Your HART"
5GTK4/libadwaita native application for the first-boot onboarding ceremony.
6Runs before any web server. Pure native UI. Zero JavaScript.
8Architecture:
9 - Imports hart_onboarding.py directly (no HTTP dependency)
10 - GTK4/libadwaita for native GNOME experience
11 - Full-screen dark ceremony with phase transitions
12 - Auto-advances with timed pauses (like the PA is speaking)
14Launch:
15 python native_onboarding.py # Normal launch
16 python native_onboarding.py --user-id 1 # Specify user
17 python native_onboarding.py --check # Just check if onboarded
19Requires: PyGObject, GTK4, libadwaita (all standard on GNOME/NixOS)
20"""
22import os
23import sys
25# Ensure HART OS root is in path
26_HART_DIR = os.environ.get(
27 'HART_INSTALL_DIR',
28 os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
29)
30if _HART_DIR not in sys.path:
31 sys.path.insert(0, _HART_DIR)
33import gi
34gi.require_version('Gtk', '4.0')
35gi.require_version('Adw', '1')
36from gi.repository import Gtk, Adw, GLib, Gdk # noqa: E402
38from hart_onboarding import ( # noqa: E402
39 HARTOnboardingSession, CONVERSATION_SCRIPT,
40 PASSION_OPTIONS, ESCAPE_OPTIONS,
41 ACKNOWLEDGMENTS_PASSION, ACKNOWLEDGMENT_ESCAPE,
42 has_hart_name, get_hart_profile,
43 ELEMENTS, SPIRITS,
44)
46# ═══════════════════════════════════════════════════════════════════════
47# CSS — Dark ceremony aesthetic
48# ═══════════════════════════════════════════════════════════════════════
50_CSS = """
51window {
52 background: #080808;
53}
55.ceremony-bg {
56 background: #080808;
57}
59/* ── Language selection ── */
61.lang-grid {
62 margin: 48px;
63}
65.lang-whisper {
66 color: rgba(255, 255, 255, 0.3);
67 font-size: 15px;
68 font-weight: 400;
69 letter-spacing: 3px;
70}
72.lang-btn {
73 background: rgba(255, 255, 255, 0.03);
74 border: 1px solid rgba(255, 255, 255, 0.06);
75 border-radius: 14px;
76 padding: 18px 28px;
77 min-width: 170px;
78 min-height: 52px;
79 transition: all 250ms ease;
80}
82.lang-btn:hover {
83 background: rgba(255, 255, 255, 0.08);
84 border-color: rgba(255, 255, 255, 0.12);
85}
87.lang-btn:active {
88 background: rgba(255, 255, 255, 0.12);
89}
91.lang-native {
92 color: #d8d8d8;
93 font-size: 19px;
94 font-weight: 400;
95}
97.lang-english {
98 color: rgba(255, 255, 255, 0.35);
99 font-size: 13px;
100 font-weight: 300;
101 margin-top: 4px;
102}
104/* ── PA text (the voice) ── */
106.pa-text {
107 color: #e0e0e0;
108 font-size: 28px;
109 font-weight: 300;
110 line-height: 1.7;
111}
113.pa-text-warm {
114 color: #f0e8e0;
115 font-size: 24px;
116 font-weight: 300;
117 line-height: 1.5;
118}
120/* ── Questions ── */
122.question-text {
123 color: #ffffff;
124 font-size: 26px;
125 font-weight: 400;
126 margin-bottom: 40px;
127}
129.option-card {
130 background: rgba(255, 255, 255, 0.04);
131 border: 1px solid rgba(255, 255, 255, 0.06);
132 border-radius: 16px;
133 padding: 22px 32px;
134 min-width: 220px;
135 min-height: 52px;
136 transition: all 250ms ease;
137}
139.option-card:hover {
140 background: rgba(255, 255, 255, 0.09);
141 border-color: rgba(255, 255, 255, 0.14);
142}
144.option-card:active {
145 background: rgba(255, 255, 255, 0.14);
146}
148.option-label {
149 color: #c8c8c8;
150 font-size: 18px;
151 font-weight: 400;
152}
154/* ── Acknowledgment ── */
156.ack-text {
157 color: rgba(255, 255, 255, 0.7);
158 font-size: 26px;
159 font-weight: 300;
160 font-style: italic;
161}
163/* ── Pre-reveal ── */
165.pre-reveal {
166 color: rgba(255, 255, 255, 0.6);
167 font-size: 30px;
168 font-weight: 300;
169 font-style: italic;
170}
172/* ── The Name ── */
174.name-reveal {
175 color: #ffffff;
176 font-size: 72px;
177 font-weight: 600;
178 letter-spacing: 8px;
179}
181.hart-tag {
182 color: rgba(255, 255, 255, 0.4);
183 font-size: 18px;
184 font-weight: 300;
185 letter-spacing: 1px;
186 margin-top: 12px;
187}
189.emoji-display {
190 font-size: 40px;
191 margin-top: 20px;
192}
194.reveal-intro {
195 color: rgba(255, 255, 255, 0.5);
196 font-size: 24px;
197 font-weight: 300;
198 margin-bottom: 32px;
199}
201/* ── Post-reveal ── */
203.post-text {
204 color: rgba(255, 255, 255, 0.5);
205 font-size: 22px;
206 font-weight: 300;
207 line-height: 1.7;
208 margin-top: 48px;
209}
211/* ── Buttons ── */
213.action-btn {
214 background: rgba(255, 255, 255, 0.06);
215 color: #d0d0d0;
216 border: 1px solid rgba(255, 255, 255, 0.08);
217 border-radius: 28px;
218 padding: 12px 36px;
219 font-size: 16px;
220 font-weight: 400;
221 min-width: 140px;
222 transition: all 200ms ease;
223}
225.action-btn:hover {
226 background: rgba(255, 255, 255, 0.12);
227}
229.action-btn-primary {
230 background: rgba(255, 255, 255, 0.10);
231 color: #ffffff;
232 font-weight: 500;
233}
235.action-btn-primary:hover {
236 background: rgba(255, 255, 255, 0.18);
237}
239/* ── Sealed identity card ── */
241.sealed-name {
242 color: #ffffff;
243 font-size: 48px;
244 font-weight: 600;
245 letter-spacing: 4px;
246}
248.sealed-tag {
249 color: rgba(255, 255, 255, 0.4);
250 font-size: 16px;
251 letter-spacing: 1px;
252 margin-top: 8px;
253}
255.sealed-message {
256 color: rgba(255, 255, 255, 0.4);
257 font-size: 17px;
258 font-weight: 300;
259 margin-top: 40px;
260}
261"""
263# Language display names (native + English)
264_LANG_DISPLAY = {
265 'en': ('English', ''),
266 'ta': ('\u0ba4\u0bae\u0bbf\u0bb4\u0bcd', 'Tamil'),
267 'hi': ('\u0939\u093f\u0928\u094d\u0926\u0940', 'Hindi'),
268 'es': ('Espa\u00f1ol', 'Spanish'),
269 'fr': ('Fran\u00e7ais', 'French'),
270 'de': ('Deutsch', 'German'),
271 'ja': ('\u65e5\u672c\u8a9e', 'Japanese'),
272 'ko': ('\ud55c\uad6d\uc5b4', 'Korean'),
273 'zh': ('\u4e2d\u6587', 'Chinese'),
274 'pt': ('Portugu\u00eas', 'Portuguese'),
275 'ar': ('\u0627\u0644\u0639\u0631\u0628\u064a\u0629', 'Arabic'),
276 'ru': ('\u0420\u0443\u0441\u0441\u043a\u0438\u0439', 'Russian'),
277}
280class HARTOnboardingWindow(Adw.ApplicationWindow):
281 """Full-screen onboarding ceremony window."""
283 def __init__(self, user_id='1', **kwargs):
284 super().__init__(**kwargs)
285 self.user_id = user_id
286 self.session = HARTOnboardingSession(user_id)
287 self._pending_timeout = None
289 self.set_default_size(1200, 800)
290 self.fullscreen()
292 # Load CSS
293 provider = Gtk.CssProvider()
294 provider.load_from_string(_CSS)
295 Gtk.StyleContext.add_provider_for_display(
296 Gdk.Display.get_default(),
297 provider,
298 Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
299 )
301 # Main stack for phase transitions
302 self.stack = Gtk.Stack(
303 transition_type=Gtk.StackTransitionType.CROSSFADE,
304 transition_duration=800,
305 )
306 self.set_content(self.stack)
308 # Build first phase
309 self._build_language_page()
311 # ── Phase builders ──────────────────────────────────────────
313 def _center_box(self, **kwargs):
314 """Create a centered vertical box (common layout)."""
315 outer = Gtk.Box(
316 orientation=Gtk.Orientation.VERTICAL,
317 halign=Gtk.Align.CENTER,
318 valign=Gtk.Align.CENTER,
319 spacing=0,
320 )
321 outer.add_css_class('ceremony-bg')
322 return outer
324 def _build_language_page(self):
325 """Phase 1: Language selection grid."""
326 page = self._center_box()
328 # Whisper title
329 title = Gtk.Label(label='CHOOSE YOUR LANGUAGE')
330 title.add_css_class('lang-whisper')
331 page.append(title)
333 spacer = Gtk.Box()
334 spacer.set_size_request(-1, 40)
335 page.append(spacer)
337 # Language grid (4 columns)
338 grid = Gtk.FlowBox(
339 max_children_per_line=4,
340 min_children_per_line=2,
341 row_spacing=16,
342 column_spacing=16,
343 selection_mode=Gtk.SelectionMode.NONE,
344 halign=Gtk.Align.CENTER,
345 homogeneous=True,
346 )
347 grid.add_css_class('lang-grid')
349 for lang_code, (native_name, english_name) in _LANG_DISPLAY.items():
350 btn = Gtk.Button()
351 btn.add_css_class('lang-btn')
353 box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
354 native_lbl = Gtk.Label(label=native_name)
355 native_lbl.add_css_class('lang-native')
356 box.append(native_lbl)
358 if english_name:
359 eng_lbl = Gtk.Label(label=english_name)
360 eng_lbl.add_css_class('lang-english')
361 box.append(eng_lbl)
363 btn.set_child(box)
364 btn.connect('clicked', self._on_language_selected, lang_code)
365 grid.append(btn)
367 page.append(grid)
368 self.stack.add_named(page, 'language')
369 self.stack.set_visible_child_name('language')
371 def _build_text_page(self, name, text, css_class='pa-text',
372 auto_advance_ms=0, next_builder=None):
373 """Build a page that shows PA text, optionally auto-advances."""
374 page = self._center_box()
376 # Clamp width for readability
377 clamp = Adw.Clamp(maximum_size=700, tightening_threshold=500)
378 label = Gtk.Label(
379 label=text,
380 wrap=True,
381 wrap_mode=2, # WORD_CHAR
382 justify=Gtk.Justification.CENTER,
383 halign=Gtk.Align.CENTER,
384 )
385 label.add_css_class(css_class)
386 clamp.set_child(label)
387 page.append(clamp)
389 self.stack.add_named(page, name)
390 self.stack.set_visible_child_name(name)
392 if auto_advance_ms and next_builder:
393 self._schedule(auto_advance_ms, next_builder)
395 def _build_question_page(self, name, question_text, options, callback):
396 """Build a question page with selectable option cards."""
397 page = self._center_box()
399 # Question
400 clamp = Adw.Clamp(maximum_size=700, tightening_threshold=500)
401 q_label = Gtk.Label(
402 label=question_text,
403 wrap=True,
404 wrap_mode=2,
405 justify=Gtk.Justification.CENTER,
406 )
407 q_label.add_css_class('question-text')
408 clamp.set_child(q_label)
409 page.append(clamp)
411 spacer = Gtk.Box()
412 spacer.set_size_request(-1, 20)
413 page.append(spacer)
415 # Options grid (2 columns)
416 grid = Gtk.FlowBox(
417 max_children_per_line=2,
418 min_children_per_line=1,
419 row_spacing=14,
420 column_spacing=14,
421 selection_mode=Gtk.SelectionMode.NONE,
422 halign=Gtk.Align.CENTER,
423 homogeneous=True,
424 )
426 for opt in options:
427 btn = Gtk.Button()
428 btn.add_css_class('option-card')
430 lbl = Gtk.Label(label=opt['label'])
431 lbl.add_css_class('option-label')
432 btn.set_child(lbl)
434 btn.connect('clicked', callback, opt['key'])
435 grid.append(btn)
437 page.append(grid)
438 self.stack.add_named(page, name)
439 self.stack.set_visible_child_name(name)
441 def _build_reveal_page(self, result):
442 """Build the name reveal page."""
443 page = self._center_box()
444 page.set_spacing(0)
446 # "I'm going to call you..."
447 intro_text = self._line('reveal_intro')
448 intro = Gtk.Label(label=intro_text)
449 intro.add_css_class('reveal-intro')
450 page.append(intro)
452 spacer1 = Gtk.Box()
453 spacer1.set_size_request(-1, 32)
454 page.append(spacer1)
456 # THE NAME (large, dramatic)
457 name_label = Gtk.Label(label=result['name'])
458 name_label.add_css_class('name-reveal')
459 page.append(name_label)
461 # HART tag: @element.spirit.name
462 tag_label = Gtk.Label(label=result.get('hart_tag', ''))
463 tag_label.add_css_class('hart-tag')
464 page.append(tag_label)
466 # Emoji combo
467 emoji_label = Gtk.Label(label=result.get('emoji_combo', ''))
468 emoji_label.add_css_class('emoji-display')
469 page.append(emoji_label)
471 spacer2 = Gtk.Box()
472 spacer2.set_size_request(-1, 48)
473 page.append(spacer2)
475 # Buttons
476 btn_box = Gtk.Box(
477 orientation=Gtk.Orientation.HORIZONTAL,
478 spacing=16,
479 halign=Gtk.Align.CENTER,
480 )
482 # "That's me" button
483 accept_btn = Gtk.Button(label="That's me")
484 accept_btn.add_css_class('action-btn')
485 accept_btn.add_css_class('action-btn-primary')
486 accept_btn.connect('clicked', self._on_accept_name)
487 btn_box.append(accept_btn)
489 # "Try another" (only if first attempt)
490 if result.get('can_try_another', True):
491 retry_btn = Gtk.Button(label='Try another')
492 retry_btn.add_css_class('action-btn')
493 retry_btn.connect('clicked', self._on_try_another)
494 btn_box.append(retry_btn)
496 page.append(btn_box)
498 self.stack.add_named(page, 'reveal')
499 self.stack.set_visible_child_name('reveal')
501 def _build_sealed_page(self, result):
502 """Build the final sealed identity page."""
503 page = self._center_box()
505 # Post-reveal PA line
506 post_text = self._line('post_reveal')
507 post = Gtk.Label(
508 label=post_text,
509 wrap=True,
510 wrap_mode=2,
511 justify=Gtk.Justification.CENTER,
512 )
513 post.add_css_class('post-text')
514 page.append(post)
516 spacer1 = Gtk.Box()
517 spacer1.set_size_request(-1, 48)
518 page.append(spacer1)
520 # The sealed name
521 name = Gtk.Label(label=result.get('hart_name', ''))
522 name.add_css_class('sealed-name')
523 page.append(name)
525 # HART tag
526 tag = result.get('hart_tag', result.get('emoji_combo', ''))
527 if tag:
528 tag_label = Gtk.Label(label=tag)
529 tag_label.add_css_class('sealed-tag')
530 page.append(tag_label)
532 # Emoji
533 emoji = result.get('emoji_combo', '')
534 if emoji:
535 emoji_label = Gtk.Label(label=emoji)
536 emoji_label.add_css_class('emoji-display')
537 page.append(emoji_label)
539 spacer2 = Gtk.Box()
540 spacer2.set_size_request(-1, 48)
541 page.append(spacer2)
543 # "Begin" button — closes the ceremony
544 begin_btn = Gtk.Button(label='Begin')
545 begin_btn.add_css_class('action-btn')
546 begin_btn.add_css_class('action-btn-primary')
547 begin_btn.connect('clicked', self._on_begin)
548 page.append(begin_btn)
550 self.stack.add_named(page, 'sealed')
551 self.stack.set_visible_child_name('sealed')
553 # ── Event handlers ──────────────────────────────────────────
555 def _on_language_selected(self, btn, lang_code):
556 """User picked their language."""
557 result = self.session.advance(
558 action='select_language',
559 data={'language': lang_code},
560 )
561 # Show greeting, then auto-advance to passion question
562 greeting = self._line('greeting')
563 self._build_text_page(
564 'greeting', greeting, 'pa-text',
565 auto_advance_ms=4000,
566 next_builder=self._show_passion,
567 )
569 def _show_passion(self):
570 """Show the passion question."""
571 lang = self.session.language
572 question = self._line('question_passion')
573 options = [{
574 'key': p['key'],
575 'label': p['labels'].get(lang, p['labels']['en']),
576 } for p in PASSION_OPTIONS]
578 self._build_question_page('passion', question, options,
579 self._on_passion_selected)
581 def _on_passion_selected(self, btn, key):
582 """User selected their passion."""
583 result = self.session.advance(action='answer', data={'key': key})
585 # Show acknowledgment
586 ack = ACKNOWLEDGMENTS_PASSION.get(key, {})
587 ack_text = ack.get(self.session.language, ack.get('en', ''))
588 self._build_text_page(
589 'ack_passion', ack_text, 'ack-text',
590 auto_advance_ms=3000,
591 next_builder=self._show_escape,
592 )
594 def _show_escape(self):
595 """Show the escape question."""
596 # Advance session past ack_passion
597 self.session.advance()
599 lang = self.session.language
600 question = self._line('question_escape')
601 options = [{
602 'key': e['key'],
603 'label': e['labels'].get(lang, e['labels']['en']),
604 } for e in ESCAPE_OPTIONS]
606 self._build_question_page('escape', question, options,
607 self._on_escape_selected)
609 def _on_escape_selected(self, btn, key):
610 """User selected their escape."""
611 result = self.session.advance(action='answer', data={'key': key})
613 # Show escape acknowledgment
614 ack_text = ACKNOWLEDGMENT_ESCAPE.get(
615 self.session.language, ACKNOWLEDGMENT_ESCAPE['en'])
616 self._build_text_page(
617 'ack_escape', ack_text, 'ack-text',
618 auto_advance_ms=3000,
619 next_builder=self._show_pre_reveal,
620 )
622 def _show_pre_reveal(self):
623 """'I think I know you.'"""
624 # Advance session past ack_escape
625 self.session.advance()
627 pre_text = self._line('pre_reveal')
628 self._build_text_page(
629 'pre_reveal', pre_text, 'pre-reveal',
630 auto_advance_ms=4000,
631 next_builder=self._show_reveal,
632 )
634 def _show_reveal(self):
635 """Generate and reveal the name."""
636 # Advance session to do the reveal
637 result = self.session.advance()
638 self._last_reveal = result
639 self._build_reveal_page(result)
641 def _on_accept_name(self, btn):
642 """Seal the name forever."""
643 result = self.session.advance(action='accept_name')
644 if result.get('sealed'):
645 self._build_sealed_page(result)
646 else:
647 # Error — show message and retry
648 error = result.get('error', 'Something went wrong')
649 self._build_text_page(
650 'error', error, 'pa-text',
651 auto_advance_ms=3000,
652 next_builder=self._show_reveal,
653 )
655 def _on_try_another(self, btn):
656 """Generate an alternative name."""
657 result = self.session.advance(action='try_another')
658 self._last_reveal = result
659 # Remove old reveal page and build new one
660 old = self.stack.get_child_by_name('reveal')
661 if old:
662 self.stack.remove(old)
663 self._build_reveal_page(result)
665 def _on_begin(self, btn):
666 """Ceremony complete — close and start the desktop."""
667 self.close()
669 # ── Utilities ───────────────────────────────────────────────
671 def _line(self, key):
672 """Get a PA line in the current session language."""
673 lines = CONVERSATION_SCRIPT.get(key, {})
674 return lines.get(self.session.language, lines.get('en', ''))
676 def _schedule(self, ms, callback):
677 """Schedule a callback after ms milliseconds."""
678 if self._pending_timeout:
679 GLib.source_remove(self._pending_timeout)
680 self._pending_timeout = GLib.timeout_add(ms, self._run_scheduled, callback)
682 def _run_scheduled(self, callback):
683 """Run a scheduled callback (returns False to prevent repeat)."""
684 self._pending_timeout = None
685 callback()
686 return False
689class HARTOnboardingApp(Adw.Application):
690 """GTK4/libadwaita application for the HART onboarding ceremony."""
692 def __init__(self, user_id='1'):
693 super().__init__(application_id='ai.hartos.onboarding')
694 self.user_id = user_id
696 def do_activate(self):
697 # Check if already onboarded
698 if has_hart_name(self.user_id):
699 profile = get_hart_profile(self.user_id)
700 if profile:
701 # Show identity card briefly, then quit
702 self._show_identity_card(profile)
703 return
705 # Run the ceremony
706 win = HARTOnboardingWindow(
707 user_id=self.user_id,
708 application=self,
709 )
710 win.present()
712 def _show_identity_card(self, profile):
713 """Show existing HART identity (already onboarded)."""
714 win = Adw.ApplicationWindow(application=self)
715 win.set_default_size(600, 400)
716 win.set_title('Your HART')
718 # Load CSS
719 provider = Gtk.CssProvider()
720 provider.load_from_string(_CSS)
721 Gtk.StyleContext.add_provider_for_display(
722 Gdk.Display.get_default(),
723 provider,
724 Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
725 )
727 box = Gtk.Box(
728 orientation=Gtk.Orientation.VERTICAL,
729 halign=Gtk.Align.CENTER,
730 valign=Gtk.Align.CENTER,
731 spacing=8,
732 )
733 box.add_css_class('ceremony-bg')
735 name = Gtk.Label(label=profile.get('name', ''))
736 name.add_css_class('sealed-name')
737 box.append(name)
739 tag = Gtk.Label(label=profile.get('hart_tag', profile.get('display', '')))
740 tag.add_css_class('sealed-tag')
741 box.append(tag)
743 emoji = Gtk.Label(label=profile.get('emoji_combo', ''))
744 emoji.add_css_class('emoji-display')
745 box.append(emoji)
747 msg = Gtk.Label(label=f"Sealed {profile.get('sealed_at', '')[:10]}")
748 msg.add_css_class('sealed-message')
749 box.append(msg)
751 win.set_content(box)
752 win.present()
755def main():
756 import argparse
757 parser = argparse.ArgumentParser(description='HART OS Onboarding')
758 parser.add_argument('--user-id', default='1', help='User ID')
759 parser.add_argument('--check', action='store_true',
760 help='Check if already onboarded and exit')
761 args = parser.parse_args()
763 if args.check:
764 if has_hart_name(args.user_id):
765 profile = get_hart_profile(args.user_id)
766 if profile:
767 print(f"Onboarded: {profile.get('hart_tag', profile.get('name'))}")
768 sys.exit(0)
769 print("Not onboarded")
770 sys.exit(1)
772 app = HARTOnboardingApp(user_id=args.user_id)
773 app.run(None)
776if __name__ == '__main__':
777 main()