From 40df700ca01c1dd575d55f2f6124a9470eeff641 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Thu, 16 Oct 2025 22:53:40 -0400 Subject: [PATCH 01/33] add kamal --- .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-app-boot.sample | 3 + .kamal/hooks/post-deploy.sample | 14 +++ .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-app-boot.sample | 3 + .kamal/hooks/pre-build.sample | 51 +++++++++++ .kamal/hooks/pre-connect.sample | 47 ++++++++++ .kamal/hooks/pre-deploy.sample | 122 ++++++++++++++++++++++++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 18 ++++ config/deploy.yml | 103 ++++++++++++++++++++++ 11 files changed, 370 insertions(+) create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-app-boot.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-app-boot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 config/deploy.yml diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 00000000..2fb07d7d --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 00000000..70f9c4bc --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 00000000..fd364c2a --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 00000000..1435a677 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 00000000..45f73550 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 00000000..c5a55678 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 00000000..77744bdc --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 00000000..05b3055b --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 00000000..061f8059 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 00000000..01c9af75 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,18 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Option 1: Read secrets from the environment +KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Option 2: Read secrets via a command +# RAILS_MASTER_KEY=$(cat config/master.key) + +# Option 3: Read secrets via kamal secrets helpers +# These will handle logging in and fetching the secrets in as few calls as possible +# There are adapters for 1Password, LastPass + Bitwarden +# +# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 00000000..f80be7e1 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,103 @@ +# Name of your application. Used to uniquely configure containers. +service: rubyui-web + +# Name of the container image. +image: rh63e/rubyui-web + +# Deploy to these servers. +servers: + web: + - 45.55.81.54 + # 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. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + ssl: true + host: rubyui.com + # Proxy connects to your container on port 80 by default. + # app_port: 3000 + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + # server: ghcr.io + username: + - KAMAL_REGISTRY_USERNAME + + # Always use an access token rather than real password (pulled from .kamal/secrets). + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + arch: amd64 + # Pass in additional build args needed for your Dockerfile. + # args: + # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %> + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +# +# env: +# clear: +# DB_HOST: 192.168.0.2 +# secret: +# - RAILS_MASTER_KEY + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal app logs -r job" will tail logs from the first server in the job section. +# +# aliases: +# shell: app exec --interactive --reuse "bash" + +# Use a different ssh user than root +# +# ssh: +# user: app + +# Use a persistent storage volume. +# +# volumes: +# - "app_storage:/app/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: /app/public/assets + +# Configure rolling deploys by setting a wait time between batches of restarts. +# +# boot: +# limit: 10 # Can also specify as a percentage of total hosts, such as "25%" +# wait: 2 + +# Use accessory services (secrets come from .kamal/secrets). +# +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# port: 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 From 1896d08469ca3508aa391dbbae78b67c8ea7a0ff Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Fri, 17 Oct 2025 19:19:06 -0400 Subject: [PATCH 02/33] add healthcheck --- config/routes.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index ecfc518e..442db63c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,5 +63,9 @@ match "/404", to: "errors#not_found", via: :all match "/500", to: "errors#internal_server_error", via: :all + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", :as => :rails_health_check + root "pages#home" end From 02694df8a9e35744bff6686195aff24289a14412 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Fri, 17 Oct 2025 21:55:04 -0400 Subject: [PATCH 03/33] add kamal with next.rubyui.com --- .kamal/secrets | 2 +- Dockerfile | 56 ++++++++++++++++++++--------------------------- Gemfile | 5 +++++ Gemfile.lock | 37 +++++++++++++++++++++++++++++++ bin/thrust | 5 +++++ config/deploy.yml | 21 +++++++++++------- 6 files changed, 85 insertions(+), 41 deletions(-) create mode 100755 bin/thrust diff --git a/.kamal/secrets b/.kamal/secrets index 01c9af75..ca72891b 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -7,7 +7,7 @@ KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Option 2: Read secrets via a command -# RAILS_MASTER_KEY=$(cat config/master.key) +RAILS_MASTER_KEY=$(cat config/credentials/production.key) # Option 3: Read secrets via kamal secrets helpers # These will handle logging in and fetching the secrets in as few calls as possible diff --git a/Dockerfile b/Dockerfile index 4459c897..481f26ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,40 @@ # syntax=docker/dockerfile:1 # check=error=true +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t jumpstart . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name jumpstart jumpstart + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.4 -FROM quay.io/evl.ms/fullstaq-ruby:${RUBY_VERSION}-jemalloc-slim AS base - -LABEL fly_launch_runtime="rails" +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails -# Update gems and bundler -RUN gem update --system --no-document && \ - gem install -N bundler - # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl sqlite3 && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 postgresql-client && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ENV BUNDLE_DEPLOYMENT="1" \ +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development:test" \ - RAILS_ENV="production" - + BUNDLE_WITHOUT="development" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems and node modules RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev node-gyp pkg-config python-is-python3 imagemagick libvips libvips-dev libvips-tools poppler-utils && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install Node.js -ARG NODE_VERSION=20.10.0 +# Install JavaScript dependencies +ARG NODE_VERSION=22.11.0 ARG PNPM_VERSION=10.8.0 ENV PATH=/usr/local/node/bin:$PATH RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ @@ -44,14 +43,14 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz rm -rf /tmp/node-build-master # Install application gems -COPY Gemfile Gemfile.lock ./ +COPY Gemfile Gemfile.lock ./.ruby-version vendor ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile # Install node modules -COPY package.json ./ -RUN pnpm install +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile # Copy application code COPY . . @@ -66,25 +65,18 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image FROM base - -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails - # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - mkdir /data && \ - chown -R 1000:1000 db log storage tmp /data + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash USER 1000:1000 -# Deployment options -ENV DATABASE_URL="sqlite3:///data/production.sqlite3" +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] -# Start the server by default, this can be overwritten at runtime -EXPOSE 3000 -VOLUME /data -CMD ["./bin/rails", "server"] +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index 73acc8bf..82780b50 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,9 @@ gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + # Use Sass to process CSS # gem "sassc-rails" @@ -82,3 +85,5 @@ gem "pry", "0.15.2" gem "tailwind_merge", "~> 1.3.1" gem "rouge", "~> 4.6" + +gem "kamal", github: "basecamp/kamal", branch: "main" diff --git a/Gemfile.lock b/Gemfile.lock index 9cd08d3a..3559f183 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,20 @@ +GIT + remote: https://github.com/basecamp/kamal.git + revision: a293409fcb64cdaec145b795ad01cba4403edbd7 + branch: main + specs: + kamal (2.7.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + GIT remote: https://github.com/phlex-ruby/phlex-rails.git revision: 2a0078767af4feea43e0772620e1d88529dc4d1a @@ -97,6 +114,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt_pbkdf (1.1.1) benchmark (0.4.1) bigdecimal (3.2.2) bindex (0.8.1) @@ -124,7 +142,9 @@ GEM reline (>= 0.3.8) dockerfile-rails (1.7.10) rails (>= 3.0.0) + dotenv (3.1.8) drb (2.2.3) + ed25519 (1.4.0) erb (5.0.2) erubi (1.13.1) globalid (1.2.1) @@ -166,14 +186,20 @@ GEM net-protocol net-protocol (0.2.2) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol + net-ssh (7.3.0) nio4r (2.7.4) nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) @@ -274,6 +300,13 @@ GEM sqlite3 (2.7.4) mini_portile2 (~> 2.8.0) sqlite3 (2.7.4-x86_64-linux-gnu) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.50.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -292,6 +325,8 @@ GEM tailwind_merge (1.3.1) sin_lru_redux (~> 2.5) thor (1.4.0) + thruster (0.1.15) + thruster (0.1.15-x86_64-linux) timeout (0.4.3) turbo-rails (2.0.16) actionpack (>= 7.1.0) @@ -328,6 +363,7 @@ DEPENDENCIES debug dockerfile-rails (>= 1.6) jsbundling-rails (= 1.3.1) + kamal! lucide-rails (= 0.7.1) phlex! phlex-rails! @@ -342,6 +378,7 @@ DEPENDENCIES standard stimulus-rails (= 1.3.4) tailwind_merge (~> 1.3.1) + thruster turbo-rails (= 2.0.16) tzinfo-data web-console diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 00000000..36bde2d8 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/deploy.yml b/config/deploy.yml index f80be7e1..e5c0473a 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -18,10 +18,10 @@ servers: # # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. proxy: - ssl: true - host: rubyui.com + # ssl: true + host: next.rubyui.com # Proxy connects to your container on port 80 by default. - # app_port: 3000 + app_port: 3000 # Credentials for your image host. registry: @@ -44,17 +44,22 @@ builder: # Inject ENV variables into containers (secrets come from .kamal/secrets). # -# env: -# clear: -# DB_HOST: 192.168.0.2 -# secret: -# - RAILS_MASTER_KEY +env: + # clear: + # DB_HOST: 192.168.0.2 + secret: + - RAILS_MASTER_KEY # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: # "bin/kamal app logs -r job" will tail logs from the first server in the job section. # # aliases: # shell: app exec --interactive --reuse "bash" +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" # Use a different ssh user than root # From 4e0e67e3743857d56a270373dbc818590fdbf6aa Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Tue, 4 Nov 2025 15:42:23 -0500 Subject: [PATCH 04/33] add blocks dynamic endpoint with iframe and update sidebar --- app/components/docs/visual_code_example.rb | 33 ++++++++++++++++++---- app/controllers/pages_controller.rb | 12 ++++++++ app/views/docs/button.rb | 8 ++---- app/views/docs/sidebar.rb | 20 +++++++++---- config/routes.rb | 5 ++-- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index c9347051..6cefb73e 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -13,14 +13,19 @@ def self.reset_collected_code @@collected_code = [] end - def initialize(title: nil, description: nil, src: nil, context: nil) + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code @title = title @description = description @src = src @context = context + @type = type + @content = content + @content_attributes = content_attributes end def view_template(&) + # @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) @display_code = CGI.unescapeHTML(capture(&)) @@collected_code << @display_code @@ -55,7 +60,7 @@ def render_header def render_tab_triggers TabsList do render_tab_trigger("preview", "Preview", method(:eye_icon)) - render_tab_trigger("code", "Code", method(:code_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) if @type == :component end end @@ -72,15 +77,25 @@ def render_tab_contents(&) end def render_preview_tab(&block) - return iframe_preview if @src + block_class_name = @content.to_s + + return iframe_preview(block_class_name) if @type == :block raw_preview end - def iframe_preview + def iframe_preview(block_name) div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border") do div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do - iframe(src: @src, class: "size-full") + if @content + iframe(src: render_block_path(id: block_name, attributes: @content_attributes), class: "size-full") + else + iframe(srcdoc: safe("
You cannot render a ruby block for a block preview
"), class: "size-full") + # TODO + # decoded_code = CGI.unescapeHTML(@display_code) + # html_content = render_block_to_html(decoded_code) + # iframe(srcdoc: safe(html_content), class: "size-full") + end end end end @@ -100,6 +115,14 @@ def render_code_tab end end + def render_block_to_html(code) + # Extract the component from "render ComponentName.new" pattern + # and evaluate it to generate standalone HTML + component_code = code.strip.sub(/^render\s+/, '') + component = eval(component_code) + component.call + end + def eye_icon svg( xmlns: "http://www.w3.org/2000/svg", diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 6ca7a4c0..b3870008 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -6,4 +6,16 @@ class PagesController < ApplicationController def home render Views::Pages::Home.new end + + def blocks + render Views::Pages::Blocks.new + end + + def render_block + self.class.layout -> { Views::Layouts::ExamplesLayout } + block_class_name = params[:id] + attributes = params[:attributes]&.permit!&.to_h&.symbolize_keys || {} + block_class = block_class_name.constantize + render block_class.new(**attributes) + end end diff --git a/app/views/docs/button.rb b/app/views/docs/button.rb index ee943b12..cc59bb42 100644 --- a/app/views/docs/button.rb +++ b/app/views/docs/button.rb @@ -45,11 +45,9 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Link", context: self) do - <<~RUBY - Button(variant: :link) { "Link" } - RUBY - end + render Docs::VisualCodeExample.new(ruby_code: <<~RUBY, title: "Link", context: self) + Button(variant: :link) { "Link" } + RUBY render Docs::VisualCodeExample.new(title: "Disabled", context: self) do <<~RUBY diff --git a/app/views/docs/sidebar.rb b/app/views/docs/sidebar.rb index 165872fa..cc1e8638 100644 --- a/app/views/docs/sidebar.rb +++ b/app/views/docs/sidebar.rb @@ -29,13 +29,21 @@ def view_template end end - render Docs::VisualCodeExample.new(title: "Example", src: "/docs/sidebar/example", context: self) do - Views::Docs::Sidebar::Example::CODE - end + render Docs::VisualCodeExample.new( + title: "Example", + context: self, + type: :block, + content: Views::Docs::Sidebar::Example, + content_attributes: {sidebar_state: "open"} + ) - render Docs::VisualCodeExample.new(title: "Inset variant", src: "/docs/sidebar/inset", context: self) do - Views::Docs::Sidebar::InsetExample::CODE - end + render Docs::VisualCodeExample.new( + title: "Inset variant", + context: self, + type: :block, + content: Views::Docs::Sidebar::InsetExample, + content_attributes: {sidebar_state: "open"} + ) render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do <<~RUBY diff --git a/config/routes.rb b/config/routes.rb index a6da0ff2..73208daa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,8 +51,6 @@ get "sheet", to: "docs#sheet", as: :docs_sheet get "shortcut_key", to: "docs#shortcut_key", as: :docs_shortcut_key get "sidebar", to: "docs#sidebar", as: :docs_sidebar - get "sidebar/example", to: "docs/sidebar#example", as: :docs_sidebar_example - get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset get "skeleton", to: "docs#skeleton", as: :docs_skeleton get "switch", to: "docs#switch", as: :docs_switch get "table", to: "docs#table", as: :docs_table @@ -63,6 +61,9 @@ get "typography", to: "docs#typography", as: :docs_typography end + get "blocks", to: "pages#blocks", as: :blocks + get "blocks/:id", to: "pages#render_block", as: :render_block + match "/404", to: "errors#not_found", via: :all match "/500", to: "errors#internal_server_error", via: :all From a12f8a191937d97cf6a3266a3309c54a5967d140 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Wed, 5 Nov 2025 17:33:29 -0500 Subject: [PATCH 05/33] add proper blocks --- .../stylesheets/application.tailwind.css | 42 ++ app/blocks/sidebar_01.rb | 287 ++++++++++++ app/blocks/sidebar_02.rb | 278 +++++++++++ app/blocks/sidebar_02/app_sidebar.rb | 217 +++++++++ app/blocks/sidebar_02/index.rb | 203 ++++++++ app/components/block_display.rb | 443 ++++++++++++++++++ app/components/block_viewer.rb | 23 + app/components/component_preview.rb | 24 + app/components/component_preview_tabs.rb | 107 +++++ app/components/component_render.rb | 17 + app/components/docs/visual_code_example.rb | 34 +- app/components/ruby_ui/codeblock/codeblock.rb | 34 +- app/controllers/pages_controller.rb | 14 +- .../block_code_viewer_controller.js | 32 ++ .../controllers/custom_tabs_controller.js | 9 + app/javascript/controllers/index.js | 9 + .../ruby_ui/collapsible_controller.js | 2 + .../controllers/ruby_ui/tabs_controller.js | 8 + .../shiki_highlighter_controller.js | 51 ++ app/views/docs/button.rb | 8 +- app/views/docs/sidebar.rb | 20 +- app/views/docs/tabs.rb | 30 ++ app/views/layouts/pages_layout.rb | 9 +- app/views/pages/base.rb | 15 + app/views/pages/blocks.rb | 119 +++++ app/views/pages/home.rb | 6 +- config/initializers/phlex.rb | 8 + config/routes.rb | 5 +- package.json | 1 + pnpm-lock.yaml | 319 +++++++++++++ 30 files changed, 2338 insertions(+), 36 deletions(-) create mode 100644 app/blocks/sidebar_01.rb create mode 100644 app/blocks/sidebar_02.rb create mode 100644 app/blocks/sidebar_02/app_sidebar.rb create mode 100644 app/blocks/sidebar_02/index.rb create mode 100644 app/components/block_display.rb create mode 100644 app/components/block_viewer.rb create mode 100644 app/components/component_preview.rb create mode 100644 app/components/component_preview_tabs.rb create mode 100644 app/components/component_render.rb create mode 100644 app/javascript/controllers/block_code_viewer_controller.js create mode 100644 app/javascript/controllers/custom_tabs_controller.js create mode 100644 app/javascript/controllers/shiki_highlighter_controller.js create mode 100644 app/views/pages/base.rb create mode 100644 app/views/pages/blocks.rb diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 8735fe66..40fc5ccf 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -49,6 +49,15 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(87 100% 37%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.97 0 0); + --code-foreground: oklch(0.145 0 0); + --code-number: oklch(0.556 0 0); + + /* spacing and text */ + --spacing: 0.25rem; + --text-sm: 0.875rem; } .dark { @@ -90,6 +99,11 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(84 81% 44%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.205 0 0); + --code-foreground: oklch(0.985 0 0); + --code-number: oklch(0.708 0 0); } @theme inline { @@ -135,6 +149,9 @@ --color-warning-foreground: var(--warning-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-code: var(--code); + --color-code-foreground: var(--code-foreground); + --color-code-number: var(--code-number); } /* Container settings */ @@ -152,3 +169,28 @@ @apply bg-background text-foreground; } } + +/* Shiki line numbers */ +code[data-line-numbers] { + counter-reset: line; + font-size: var(--text-sm); +} + +[data-line-numbers] [data-line]::before { + font-size: var(--text-sm); + counter-increment: line; + content: counter(line); + width: calc(var(--spacing) * 16); + padding-right: calc(var(--spacing) * 6); + text-align: right; + color: var(--color-code-number); + background-color: var(--color-code); + display: inline-block; + position: sticky; + left: 0; +} + +[data-line-numbers] [data-line] { + display: inline-block; + width: 100%; +} diff --git a/app/blocks/sidebar_01.rb b/app/blocks/sidebar_01.rb new file mode 100644 index 00000000..906686ad --- /dev/null +++ b/app/blocks/sidebar_01.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar01 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon() + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon() + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon() + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: { strategy: "fixed", placement: "right-start" }) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02.rb b/app/blocks/sidebar_02.rb new file mode 100644 index 00000000..24907358 --- /dev/null +++ b/app/blocks/sidebar_02.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar02 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def view_template + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02/app_sidebar.rb b/app/blocks/sidebar_02/app_sidebar.rb new file mode 100644 index 00000000..6b5e339f --- /dev/null +++ b/app/blocks/sidebar_02/app_sidebar.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +class Blocks::Sidebar02::AppSidebar < Views::Base + def view_template + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + Blocks::Sidebar02::Index::FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + Blocks::Sidebar02::Index::WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def message_circle_question + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_line_join: "round", + class: "lucide lucide-message-circle-question-icon lucide-message-circle-question" + ) do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end +end diff --git a/app/blocks/sidebar_02/index.rb b/app/blocks/sidebar_02/index.rb new file mode 100644 index 00000000..b6efbe53 --- /dev/null +++ b/app/blocks/sidebar_02/index.rb @@ -0,0 +1,203 @@ +class Blocks::Sidebar02::Index < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + render Blocks::Sidebar02::AppSidebar.new + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + Separator(orientation: :vertical, class: "mr-2 h-4") + Breadcrumb do + BreadcrumbList do + BreadcrumbItem(class: "hidden md:block") do + BreadcrumbLink(href: "#") { "Building Your Application" } + end + BreadcrumbSeparator(class: "hidden md:block") + BreadcrumbItem do + BreadcrumbPage { "Data Fetching" } + end + end + end + end + div(class: "flex flex-1 flex-col gap-4 p-4") do + div(class: "grid auto-rows-min gap-4 md:grid-cols-3") do + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + end + div(class: "min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/components/block_display.rb b/app/components/block_display.rb new file mode 100644 index 00000000..b521ec1c --- /dev/null +++ b/app/components/block_display.rb @@ -0,0 +1,443 @@ +class Components::BlockDisplay < Components::Base + def initialize(content:, description: nil) + @description = description + @content = content + @files = extract_files_from_block + end + + def view_template + div( + class: "group/block-view-wrapper", + data: { + controller: "custom-tabs block-code-viewer", + tab: "preview", + action: "tab-change->custom-tabs#setTab" + } + ) do + tool_bar + block_viewer_view + block_viewer_code + end + end + + def tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + Separator(orientation: :vertical) + + render_header + end + end + end + end + + def render_tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + end + end + end + + def block_viewer_view + div(class: "hidden group-data-[tab=code]/block-view-wrapper:hidden md:h-(--height) lg:flex") do + div(class: "relative grid w-full gap-4") do + div(class: "absolute inset-0 right-4 [background-image:radial-gradient(#d4d4d4_1px,transparent_1px)] [background-size:20px_20px] dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]") + render_preview_tab + end + end + end + + def block_viewer_code + div(class: "bg-code text-code-foreground mr-[14px] flex overflow-hidden rounded-xl border group-data-[tab=preview]/block-view-wrapper:hidden h-[600px]") do + render_file_tree + render_code_content + end + end + + def render_file_tree + div(class: "w-72") do + div( + data_slot: "sidebar-wrapper", + style: "--sidebar-width:16rem;--sidebar-width-icon:3rem", + class: "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar min-h-svh w-full flex !min-h-full flex-col border-r" + ) do + div( + data_slot: "sidebar", + class: "bg-sidebar text-sidebar-foreground flex h-full flex-col w-full flex-1" + ) do + SidebarGroupLabel(data_slot: "sidebar-group-label", class: "h-12 rounded-none border-b px-4 text-sm") { "Files" } + SidebarGroup(data_slot: "sidebar-group", class: "p-0") do + SidebarGroupContent(data_slot: "sidebar-group-content") do + SidebarMenu(data_slot: "sidebar-menu", class: "translate-x-0 gap-1.5") do + render_file_tree_items + end + end + end + end + end + end + end + + def render_file_tree_items + grouped_files = group_files_by_directory + grouped_files.each do |dir, files| + render_directory_item(dir, files) + end + end + + def render_directory_item(dir, files) + depth = dir.split("/").size - 1 + padding_left = "#{1 + (depth * 1.4)}rem" + + SidebarMenuItem(data_slot: "sidebar-menu-item") do + Collapsible( + open: true, + data_slot: "collapsible", + class: "group/collapsible" + ) do + CollapsibleTrigger(data_slot: "collapsible-trigger") do + SidebarMenuButton( + as: :button, + type: "button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}" + ) do + chevron_icon(collapsible: true) + folder_icon + span { dir.split("/").last } + end + end + CollapsibleContent(data_slot: "collapsible-content") do + SidebarMenuSub(data_slot: "sidebar-menu-sub", class: "m-0 w-full translate-x-0 border-none p-0") do + files.each_with_index do |file, index| + render_file_item(file, index, depth + 1) + end + end + end + end + end + end + + def render_file_item(file, index, depth) + padding_left = "#{1 + (depth * 1.4) + 0.5}rem" + + SidebarMenuSubItem(data_slot: "sidebar-menu-item") do + SidebarMenuButton( + active: index == 0, + as: :button, + data_slot: "sidebar-menu-button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}", + data: { + action: "click->block-code-viewer#selectFile", + file_path: file[:path], + block_code_viewer_target: "fileButton", + index: depth + } + ) do + chevron_icon(invisible: true) + file_icon + span { file[:name] } + end + end + end + + def group_files_by_directory + grouped = {} + @files.each do |file| + dir = File.dirname(file[:path]) + grouped[dir] ||= [] + grouped[dir] << file + end + grouped + end + + def render_code_content + figure(class: "!mx-0 mt-0 flex min-w-0 flex-1 flex-col rounded-xl border-none overflow-hidden") do + render_file_header + render_code_body + end + end + + def render_file_header + @files.each_with_index do |file, index| + figcaption( + class: "text-code-foreground [&_svg]:text-code-foreground relative flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 [&_svg]:size-4 [&_svg]:opacity-70 #{(index == 0) ? "" : "hidden"}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileHeader" + } + ) do + file_icon + span(class: "text-sm font-light") { file[:path] } + + # Clipboard button + RubyUI.Clipboard(success: "Copied!", error: "Copy failed!", class: "absolute right-2") do + RubyUI.ClipboardSource do + pre(class: "hidden") { plain file[:code] } + end + RubyUI.ClipboardTrigger do + RubyUI.Button( + variant: :ghost, + size: :icon, + class: "size-7" + ) { clipboard_icon } + end + end + end + end + end + + def render_code_body + @files.each_with_index do |file, index| + div( + class: "overflow-y-auto flex-1 min-h-0 #{(index == 0) ? "" : "hidden"}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileContent" + } + ) do + render CodeblockWithLineNumbers.new(code: file[:code], syntax: file[:syntax]) + end + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents + TabsContent(value: "preview") { render_preview_tab } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock("@display_code", syntax: :ruby, class: "-m-px") + end + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + + def chevron_icon(invisible: false, collapsible: false) + classes = ["lucide", "lucide-chevron-right", "transition-transform", "duration-200"] + classes << "invisible" if invisible + classes << "group-data-[state=open]/collapsible:rotate-90" if collapsible + + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: classes.join(" ") + ) do |s| + s.path(d: "m9 18 6-6-6-6") + end + end + + def folder_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-folder" + ) do |s| + s.path(d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z") + end + end + + def file_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-file h-4 w-4" + ) do |s| + s.path(d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z") + s.path(d: "M14 2v4a2 2 0 0 0 2 2h4") + end + end + + def clipboard_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-clipboard" + ) do |s| + s.rect(width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1") + s.path(d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2") + end + end + + private + + def extract_files_from_block + files = [] + + main_file_path = get_file_path_for_class(@content) + if File.exist?(main_file_path) + content = File.read(main_file_path) + files << { + name: File.basename(main_file_path), + path: relative_path_from_app(main_file_path), + code: content, + syntax: :ruby + } + end + + if @content.to_s.include?("::") + dir_path = File.dirname(main_file_path) + if File.directory?(dir_path) && dir_path != File.dirname(dir_path) + Dir.glob("#{dir_path}/*.rb").sort.each do |file_path| + next if file_path == main_file_path + content = File.read(file_path) + files << { + name: File.basename(file_path), + path: relative_path_from_app(file_path), + code: content, + syntax: :ruby + } + end + end + end + + files + end + + def get_file_path_for_class(klass) + parts = klass.to_s.split("::") + filename = parts.pop.underscore + path_parts = parts.map(&:underscore) + + Rails.root.join("app", *path_parts, "#{filename}.rb").to_s + end + + def relative_path_from_app(absolute_path) + absolute_path.sub(Rails.root.join("app").to_s + "/", "") + end + + # Inner component for rendering code with Shiki highlighting + class CodeblockWithLineNumbers < Components::Base + def initialize(code:, syntax:) + @code = code + @syntax = syntax + end + + def view_template + div( + class: "relative", + data: { + controller: "shiki-highlighter", + shiki_highlighter_language_value: @syntax.to_s + } + ) do + # Hidden code content for Shiki to process + pre( + class: "hidden", + data: {shiki_highlighter_target: "code"} + ) do + plain @code + end + + # Output container for Shiki-generated HTML + div( + class: "bg-code text-code-foreground overflow-y-auto", + data: {shiki_highlighter_target: "output"} + ) + end + end + end +end diff --git a/app/components/block_viewer.rb b/app/components/block_viewer.rb new file mode 100644 index 00000000..d3879ccc --- /dev/null +++ b/app/components/block_viewer.rb @@ -0,0 +1,23 @@ +class Components::BlockViewer < Components::Base + def view_template + render ComponentPreview.new(title: "Example", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + # render ComponentPreview.new( + # title: "Example", + # context: self, + # type: :block, + # content: Blocks::Sidebar02::Index, + # content_attributes: {sidebar_state: "open"} + # ) + end +end diff --git a/app/components/component_preview.rb b/app/components/component_preview.rb new file mode 100644 index 00000000..c2da35fb --- /dev/null +++ b/app/components/component_preview.rb @@ -0,0 +1,24 @@ +module Components + class ComponentPreview < Components::Base + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code + @title = title + @description = description + @src = src + @context = context + @type = type + @content = content + @content_attributes = content_attributes + end + + def view_template(&) + if @type == :block && @content + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + else + render ComponentPreviewTabs.new(title: @title, description: @description, context: @context, ruby_code: @ruby_code) { capture(&) } + end + end + end +end diff --git a/app/components/component_preview_tabs.rb b/app/components/component_preview_tabs.rb new file mode 100644 index 00000000..230a6877 --- /dev/null +++ b/app/components/component_preview_tabs.rb @@ -0,0 +1,107 @@ +module Components + class ComponentPreviewTabs < Components::Base + def initialize(context:, ruby_code: nil, title: nil, description: nil) + @title = title + @description = description + @context = context + @ruby_code = ruby_code + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + div(id: @title) do + div(class: "relative") do + Tabs(default_value: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + render_header + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + render_tab_contents(&) + end + end + end + # render ComponentRender.new(context: @context, ruby_code: @ruby_code) { capture(&) } + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents(&) + TabsContent(value: "preview") { render_preview_tab(&) } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab(&) + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock(@display_code, syntax: :ruby, class: "-m-px") + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + end +end diff --git a/app/components/component_render.rb b/app/components/component_render.rb new file mode 100644 index 00000000..a2f77b28 --- /dev/null +++ b/app/components/component_render.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Components + class ComponentRender < Components::Base + def initialize(context:, ruby_code: nil) + @ruby_code = ruby_code + @context = context + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + # standard:enable Style/ArgumentsForwarding + end +end diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..12aabd6b 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -13,15 +13,19 @@ def self.reset_collected_code @@collected_code = [] end - def initialize(title: nil, description: nil, src: nil, context: nil) + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code @title = title @description = description @src = src @context = context + @type = type + @content = content + @content_attributes = content_attributes end def view_template(&) - @display_code = CGI.unescapeHTML(capture(&)) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) @@collected_code << @display_code div(id: @title) do @@ -55,7 +59,7 @@ def render_header def render_tab_triggers TabsList do render_tab_trigger("preview", "Preview", method(:eye_icon)) - render_tab_trigger("code", "Code", method(:code_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) if @type == :component end end @@ -72,15 +76,25 @@ def render_tab_contents(&) end def render_preview_tab(&block) - return iframe_preview if @src + block_class_name = @content.to_s + + return iframe_preview(block_class_name) if @type == :block raw_preview end - def iframe_preview + def iframe_preview(block_name) div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do - iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) + if @content + iframe(src: render_block_path(id: block_name, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + else + iframe(srcdoc: safe("
You cannot render a ruby block for a block preview
"), class: "size-full") + # TODO + # decoded_code = CGI.unescapeHTML(@display_code) + # html_content = render_block_to_html(decoded_code) + # iframe(srcdoc: safe(html_content), class: "size-full") + end end end end @@ -100,6 +114,14 @@ def render_code_tab end end + def render_block_to_html(code) + # Extract the component from "render ComponentName.new" pattern + # and evaluate it to generate standalone HTML + # component_code = code.strip.sub(/^render\s+/, "") + # component = eval(component_code) + # component.call + end + def eye_icon svg( xmlns: "http://www.w3.org/2000/svg", diff --git a/app/components/ruby_ui/codeblock/codeblock.rb b/app/components/ruby_ui/codeblock/codeblock.rb index 2e74f152..51f1900f 100644 --- a/app/components/ruby_ui/codeblock/codeblock.rb +++ b/app/components/ruby_ui/codeblock/codeblock.rb @@ -1,12 +1,7 @@ # frozen_string_literal: true -require "rouge" - module RubyUI class Codeblock < Base - FORMATTER = ::Rouge::Formatters::HTML.new - ROUGE_CSS = Rouge::Themes::Github.mode(:dark).render(scope: ".highlight") # See themes here: https://rouge-ruby.github.io/docs/Rouge/CSSTheme.html - def initialize(code, syntax:, clipboard: true, clipboard_success: "Copied!", clipboard_error: "Copy failed!", **attrs) @code = code @syntax = syntax.to_sym @@ -22,7 +17,6 @@ def initialize(code, syntax:, clipboard: true, clipboard_success: "Copied!", cli end def view_template - style { ROUGE_CSS } # For faster load times, move this to the head of your document. (Also move ROUGE_CSS value to head of document) if @clipboard with_clipboard else @@ -35,7 +29,7 @@ def view_template def default_attrs { style: {tab_size: 2}, - class: "highlight text-sm max-h-[350px] after:content-none flex font-mono overflow-auto overflow-x rounded-md border !bg-stone-900 [&_pre]:p-4" + class: "max-h-[350px] font-mono overflow-auto rounded-md border" } end @@ -54,16 +48,30 @@ def with_clipboard def codeblock div(**attrs) do - div(class: "after:content-none") do - pre { raw(safe(FORMATTER.format(lexer.lex(@code)))) } + div( + class: "relative", + data: { + controller: "shiki-highlighter", + shiki_highlighter_language_value: @syntax.to_s + } + ) do + # Hidden code content for Shiki to process + pre( + class: "hidden", + data: {shiki_highlighter_target: "code"} + ) do + plain @code + end + + # Output container for Shiki-generated HTML + div( + class: "overflow-auto", + data: {shiki_highlighter_target: "output"} + ) end end end - def lexer - Rouge::Lexer.find(@syntax) - end - def clipboard_icon svg( xmlns: "http://www.w3.org/2000/svg", diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 6ca7a4c0..8d6886d4 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,9 +1,21 @@ # frozen_string_literal: true class PagesController < ApplicationController - layout -> { Views::Layouts::PagesLayout } + layout false def home render Views::Pages::Home.new end + + def blocks + render Views::Pages::Blocks.new + end + + def render_block + self.class.layout -> { Views::Layouts::ExamplesLayout } + block_class_name = params[:id] + attributes = params[:attributes]&.permit!&.to_h&.symbolize_keys || {} + block_class = block_class_name.constantize + render block_class.new(**attributes) + end end diff --git a/app/javascript/controllers/block_code_viewer_controller.js b/app/javascript/controllers/block_code_viewer_controller.js new file mode 100644 index 00000000..0a3329c9 --- /dev/null +++ b/app/javascript/controllers/block_code_viewer_controller.js @@ -0,0 +1,32 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["fileButton", "fileHeader", "fileContent"] + + selectFile(event) { + const selectedPath = event.currentTarget.dataset.filePath + + // Update button states + this.fileButtonTargets.forEach(button => { + const isSelected = button.dataset.filePath === selectedPath + button.dataset.active = isSelected.toString() + button.classList.toggle("bg-accent", isSelected) + button.classList.toggle("text-accent-foreground", isSelected) + button.classList.toggle("text-muted-foreground", !isSelected) + }) + + // Update file headers + this.fileHeaderTargets.forEach(header => { + const isSelected = header.dataset.filePath === selectedPath + header.classList.toggle("hidden", !isSelected) + }) + + // Update file contents + this.fileContentTargets.forEach(content => { + const isSelected = content.dataset.filePath === selectedPath + content.classList.toggle("hidden", !isSelected) + }) + } +} + + diff --git a/app/javascript/controllers/custom_tabs_controller.js b/app/javascript/controllers/custom_tabs_controller.js new file mode 100644 index 00000000..770ab68c --- /dev/null +++ b/app/javascript/controllers/custom_tabs_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="custom-tabs" +export default class extends Controller { + setTab(event) { + this.element.dataset.tab = event.detail.value; + } +} + diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 9047cd23..34321fd1 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -87,3 +87,12 @@ application.register("iframe-theme", IframeThemeController) import SidebarMenuController from "./sidebar_menu_controller" application.register("sidebar-menu", SidebarMenuController) + +import CustomTabsController from "./custom_tabs_controller" +application.register("custom-tabs", CustomTabsController) + +import BlockCodeViewerController from "./block_code_viewer_controller" +application.register("block-code-viewer", BlockCodeViewerController) + +import ShikiHighlighterController from "./shiki_highlighter_controller" +application.register("shiki-highlighter", ShikiHighlighterController) diff --git a/app/javascript/controllers/ruby_ui/collapsible_controller.js b/app/javascript/controllers/ruby_ui/collapsible_controller.js index cb367da3..9d819fe9 100644 --- a/app/javascript/controllers/ruby_ui/collapsible_controller.js +++ b/app/javascript/controllers/ruby_ui/collapsible_controller.js @@ -33,6 +33,7 @@ export default class extends Controller { open() { if (this.hasContentTarget) { this.contentTarget.classList.remove('hidden') + this.element.dataset.state = 'open' this.openValue = true } } @@ -41,6 +42,7 @@ export default class extends Controller { close() { if (this.hasContentTarget) { this.contentTarget.classList.add('hidden') + this.element.dataset.state = 'closed' this.openValue = false } } diff --git a/app/javascript/controllers/ruby_ui/tabs_controller.js b/app/javascript/controllers/ruby_ui/tabs_controller.js index e46d69c4..cf5743bd 100644 --- a/app/javascript/controllers/ruby_ui/tabs_controller.js +++ b/app/javascript/controllers/ruby_ui/tabs_controller.js @@ -29,6 +29,14 @@ export default class extends Controller { this.activeContentTarget() && this.activeContentTarget().classList.remove("hidden"); this.activeTriggerTarget().dataset.state = "active"; + + // Dispatch custom event for external listeners + this.element.dispatchEvent( + new CustomEvent("tab-change", { + detail: { value: currentValue }, + bubbles: true + }) + ); } activeTriggerTarget() { diff --git a/app/javascript/controllers/shiki_highlighter_controller.js b/app/javascript/controllers/shiki_highlighter_controller.js new file mode 100644 index 00000000..c62e83fa --- /dev/null +++ b/app/javascript/controllers/shiki_highlighter_controller.js @@ -0,0 +1,51 @@ +import { Controller } from "@hotwired/stimulus" +import { codeToHtml } from "shiki" + +export default class extends Controller { + static targets = ["code", "output"] + static values = { + language: { type: String, default: "ruby" } + } + + async connect() { + await this.highlightCode() + } + + async highlightCode() { + if (!this.hasCodeTarget || !this.hasOutputTarget) return + + const code = this.codeTarget.textContent + const lang = this.languageValue + + const isDark = document.documentElement.classList.contains('dark') + const theme = isDark ? 'github-dark' : 'github-light' + + try { + const html = await codeToHtml(code, { + lang: lang, + theme: theme, + transformers: [ + { + pre(node) { + node.properties["class"] = "no-scrollbar min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 !bg-transparent" + node.properties["data-line-numbers"] = "" + }, + code(node) { + node.properties["data-line-numbers"] = "" + }, + line(node, line) { + node.properties["data-line"] = line + this.addClassToHast(node, "line") + } + } + ] + }) + + this.outputTarget.innerHTML = html + } catch (error) { + console.error('Shiki highlighting error:', error) + this.outputTarget.textContent = code + } + } +} + diff --git a/app/views/docs/button.rb b/app/views/docs/button.rb index ee943b12..cc59bb42 100644 --- a/app/views/docs/button.rb +++ b/app/views/docs/button.rb @@ -45,11 +45,9 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Link", context: self) do - <<~RUBY - Button(variant: :link) { "Link" } - RUBY - end + render Docs::VisualCodeExample.new(ruby_code: <<~RUBY, title: "Link", context: self) + Button(variant: :link) { "Link" } + RUBY render Docs::VisualCodeExample.new(title: "Disabled", context: self) do <<~RUBY diff --git a/app/views/docs/sidebar.rb b/app/views/docs/sidebar.rb index 165872fa..cc1e8638 100644 --- a/app/views/docs/sidebar.rb +++ b/app/views/docs/sidebar.rb @@ -29,13 +29,21 @@ def view_template end end - render Docs::VisualCodeExample.new(title: "Example", src: "/docs/sidebar/example", context: self) do - Views::Docs::Sidebar::Example::CODE - end + render Docs::VisualCodeExample.new( + title: "Example", + context: self, + type: :block, + content: Views::Docs::Sidebar::Example, + content_attributes: {sidebar_state: "open"} + ) - render Docs::VisualCodeExample.new(title: "Inset variant", src: "/docs/sidebar/inset", context: self) do - Views::Docs::Sidebar::InsetExample::CODE - end + render Docs::VisualCodeExample.new( + title: "Inset variant", + context: self, + type: :block, + content: Views::Docs::Sidebar::InsetExample, + content_attributes: {sidebar_state: "open"} + ) render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do <<~RUBY diff --git a/app/views/docs/tabs.rb b/app/views/docs/tabs.rb index 33b6ff34..34bc6688 100644 --- a/app/views/docs/tabs.rb +++ b/app/views/docs/tabs.rb @@ -129,6 +129,36 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Custom Tabs", context: self) do + <<~RUBY + div( + class: "group/custom-tab", + data: { + controller: "custom-tabs", + tab: "first", + action: "tab-change->custom-tabs#setTab" + } + ) do + div(class: "block") do + Tabs(default: "first") do + TabsList do + TabsTrigger(value: "first") { "first" } + TabsTrigger(value: "second") { "second" } + end + end + end + + div(class: "hidden group-data-[tab=second]/custom-tab:hidden md:h-50 lg:flex") do + plain "first1" + end + + div(class: "bg-code text-code-foreground mr-[14px] flex overflow-hidden rounded-xl border group-data-[tab=first]/custom-tab:hidden md:h-50") do + plain "first2" + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/app/views/layouts/pages_layout.rb b/app/views/layouts/pages_layout.rb index 98bfc95e..9dfb5653 100644 --- a/app/views/layouts/pages_layout.rb +++ b/app/views/layouts/pages_layout.rb @@ -5,7 +5,12 @@ module Layouts class PagesLayout < Views::Base include Phlex::Rails::Layout - def view_template(&block) + def initialize(page_info = nil, **user_attrs) + @page_info = page_info + super(**user_attrs) + end + + def view_template doctype html do @@ -13,7 +18,7 @@ def view_template(&block) body do render Shared::Navbar.new - main(class: "relative", &block) + main(class: "relative") { yield } render Shared::Flashes.new(notice: flash[:notice], alert: flash[:alert]) end end diff --git a/app/views/pages/base.rb b/app/views/pages/base.rb new file mode 100644 index 00000000..f95a6624 --- /dev/null +++ b/app/views/pages/base.rb @@ -0,0 +1,15 @@ +class Views::Pages::Base < Views::Base + PageInfo = Data.define(:title) + + def around_template + render layout.new(page_info) do + super + end + end + + def page_info + PageInfo.new( + title: page_title + ) + end +end diff --git a/app/views/pages/blocks.rb b/app/views/pages/blocks.rb new file mode 100644 index 00000000..28832de2 --- /dev/null +++ b/app/views/pages/blocks.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +class Views::Pages::Blocks < Views::Pages::Base + def layout = Views::Layouts::PagesLayout + + def page_title = "Building Blocks for the Web" + + def view_template + div(class: "mx-auto w-full max-w-5xl md:max-w-6xl px-4 py-14 md:py-16 space-y-10") do + div(class: "text-center space-y-4") do + Components.Heading(level: 1, as: "h1", class: "text-4xl md:text-6xl font-bold tracking-tight") { "Building Blocks for the Web" } + p(class: "text-muted-foreground text-base md:text-lg") { "Clean, modern building blocks. Copy and paste into your apps." } + p(class: "text-muted-foreground text-base md:text-lg") { "Works with all React frameworks. Open Source. Free forever." } + div(class: "flex flex-col sm:flex-row items-center justify-center gap-3 pt-2") do + Link(variant: :primary, href: blocks_path, class: "px-5") { "Browse Blocks" } + Link(variant: :ghost, href: docs_introduction_path, class: "px-5") { "Add a block" } + end + end + + # render Components::BlockViewer.new + # render ComponentPreview.new(title: "Example", context: self, type: :block, content: Blocks::Sidebar02) + # + render BlockDisplay.new(description: "A dashboard with sidebar, charts and data table", content: Blocks::Sidebar02) + # render Docs::VisualCodeExample.new( + # title: "Example", + # context: self, + # type: :block, + # content: Blocks::Sidebar02, + # ) + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def external_icon_link + svg( + xmlns: "http://www.w3.org/2000/svg", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-external-link-icon lucide-external-link size-3" + ) do |s| + s.path(d: "M15 3h6v6") + s.path(d: "M10 14 21 3") + s.path(d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6") + end + end + + def info_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-5 h-5" + ) do |s| + s.path( + fill_rule: "evenodd", + d: + "M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z", + clip_rule: "evenodd" + ) + end + end +end diff --git a/app/views/pages/home.rb b/app/views/pages/home.rb index 435b07d8..6dc93885 100644 --- a/app/views/pages/home.rb +++ b/app/views/pages/home.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true -class Views::Pages::Home < Views::Base +class Views::Pages::Home < Views::Pages::Base + def layout = Views::Layouts::PagesLayout + + def page_title = "RubyUI" + def view_template render HomeView::Banner.new do |banner| banner.cta do diff --git a/config/initializers/phlex.rb b/config/initializers/phlex.rb index 13fd15ae..7f30d9a7 100644 --- a/config/initializers/phlex.rb +++ b/config/initializers/phlex.rb @@ -7,6 +7,10 @@ module Components extend Phlex::Kit end +module Blocks + extend Phlex::Kit +end + Rails.autoloaders.main.push_dir( "#{Rails.root}/app/views", namespace: Views ) @@ -14,3 +18,7 @@ module Components Rails.autoloaders.main.push_dir( "#{Rails.root}/app/components", namespace: Components ) + +Rails.autoloaders.main.push_dir( + "#{Rails.root}/app/blocks", namespace: Blocks +) diff --git a/config/routes.rb b/config/routes.rb index a6da0ff2..73208daa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,8 +51,6 @@ get "sheet", to: "docs#sheet", as: :docs_sheet get "shortcut_key", to: "docs#shortcut_key", as: :docs_shortcut_key get "sidebar", to: "docs#sidebar", as: :docs_sidebar - get "sidebar/example", to: "docs/sidebar#example", as: :docs_sidebar_example - get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset get "skeleton", to: "docs#skeleton", as: :docs_skeleton get "switch", to: "docs#switch", as: :docs_switch get "table", to: "docs#table", as: :docs_table @@ -63,6 +61,9 @@ get "typography", to: "docs#typography", as: :docs_typography end + get "blocks", to: "pages#blocks", as: :blocks + get "blocks/:id", to: "pages#render_block", as: :render_block + match "/404", to: "errors#not_found", via: :all match "/500", to: "errors#internal_server_error", via: :all diff --git a/package.json b/package.json index b323ba73..22219a44 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "maska": "3.2.0", "motion": "12.23.16", "mustache": "4.2.0", + "shiki": "^3.14.0", "tailwindcss": "4.1.14", "tippy.js": "6.3.7", "tw-animate-css": "1.3.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a147f925..84a8c5e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: mustache: specifier: 4.2.0 version: 4.2.0 + shiki: + specifier: ^3.14.0 + version: 3.14.0 tailwindcss: specifier: 4.1.14 version: 4.1.14 @@ -249,6 +252,27 @@ packages: '@rails/actioncable@8.0.200': resolution: {integrity: sha512-EDqWyxck22BHmv1e+mD8Kl6GmtNkhEPdRfGFT7kvsv1yoXd9iYrqHDVAaR8bKmU/syC5eEZ2I5aWWxtB73ukMw==} + '@shikijs/core@3.14.0': + resolution: {integrity: sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==} + + '@shikijs/engine-javascript@3.14.0': + resolution: {integrity: sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ==} + + '@shikijs/engine-oniguruma@3.14.0': + resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} + + '@shikijs/langs@3.14.0': + resolution: {integrity: sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==} + + '@shikijs/themes@3.14.0': + resolution: {integrity: sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==} + + '@shikijs/types@3.14.0': + resolution: {integrity: sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tailwindcss/forms@0.5.10': resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} peerDependencies: @@ -259,6 +283,18 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -274,6 +310,15 @@ packages: caniuse-lite@1.0.30001713: resolution: {integrity: sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} @@ -285,11 +330,21 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + electron-to-chromium@1.5.136: resolution: {integrity: sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ==} @@ -326,6 +381,15 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} @@ -338,6 +402,24 @@ packages: maska@3.2.0: resolution: {integrity: sha512-zSmSgs5/q9vMSmrdZT3rKOv9uLznNWR/niuuAdBZDTvB3SMKOX9vhMtDijFyExz+B4UClu2rvksylUh/ea1bLA==} + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + mini-svg-data-uri@1.4.4: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true @@ -378,6 +460,12 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.3: + resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -392,22 +480,61 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + + shiki@3.14.0: + resolution: {integrity: sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + tailwindcss@4.1.14: resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tw-animate-css@1.3.8: resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -417,6 +544,15 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@esbuild/aix-ppc64@0.25.11': @@ -523,6 +659,39 @@ snapshots: '@rails/actioncable@8.0.200': {} + '@shikijs/core@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.3 + + '@shikijs/engine-oniguruma@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/themes@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/types@3.14.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)': dependencies: mini-svg-data-uri: 1.4.4 @@ -536,6 +705,18 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.14 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + autoprefixer@10.4.21(postcss@8.5.3): dependencies: browserslist: 4.24.4 @@ -555,6 +736,12 @@ snapshots: caniuse-lite@1.0.30001713: {} + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chart.js@4.5.1: dependencies: '@kurkle/color': 0.3.4 @@ -565,8 +752,16 @@ snapshots: clsx@2.1.1: {} + comma-separated-tokens@2.0.3: {} + cssesc@3.0.0: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + electron-to-chromium@1.5.136: {} embla-carousel@8.6.0: {} @@ -612,6 +807,26 @@ snapshots: fuse.js@7.1.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-void-elements@3.0.0: {} + lodash.castarray@4.4.0: {} lodash.isplainobject@4.0.6: {} @@ -620,6 +835,35 @@ snapshots: maska@3.2.0: {} + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + mini-svg-data-uri@1.4.4: {} motion-dom@12.23.12: @@ -641,6 +885,14 @@ snapshots: normalize-range@0.1.2: {} + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.3: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.0.1 + regex-recursion: 6.0.2 + picocolors@1.1.1: {} postcss-selector-parser@6.0.10: @@ -656,18 +908,73 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + property-information@7.1.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + + shiki@3.14.0: + dependencies: + '@shikijs/core': 3.14.0 + '@shikijs/engine-javascript': 3.14.0 + '@shikijs/engine-oniguruma': 3.14.0 + '@shikijs/langs': 3.14.0 + '@shikijs/themes': 3.14.0 + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + tailwindcss@4.1.14: {} tippy.js@6.3.7: dependencies: '@popperjs/core': 2.11.8 + trim-lines@3.0.1: {} + tslib@2.8.1: {} tw-animate-css@1.3.8: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.1.3(browserslist@4.24.4): dependencies: browserslist: 4.24.4 @@ -675,3 +982,15 @@ snapshots: picocolors: 1.1.1 util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + zwitch@2.0.4: {} From 2c1b55f668c494eb4d7e84d07388f3fe0cb22e4b Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Thu, 20 Nov 2025 11:18:05 -0800 Subject: [PATCH 06/33] =?UTF-8?q?=F0=9F=8D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index f4cc42d6..25b20986 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,6 +117,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.19.0) @@ -141,7 +142,9 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + dotenv (3.1.8) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.0) erubi (1.13.1) globalid (1.3.0) @@ -190,6 +193,7 @@ GEM net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol + net-ssh (7.3.0) nio4r (2.7.5) nokogiri (1.18.10) mini_portile2 (~> 2.8.2) @@ -299,6 +303,13 @@ GEM sqlite3 (2.8.0) mini_portile2 (~> 2.8.0) sqlite3 (2.8.0-x86_64-linux-gnu) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.52.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -317,6 +328,8 @@ GEM tailwind_merge (1.3.1) sin_lru_redux (~> 2.5) thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-x86_64-linux) timeout (0.4.4) tsort (0.2.0) turbo-rails (2.0.20) @@ -368,6 +381,7 @@ DEPENDENCIES standard stimulus-rails (= 1.3.4) tailwind_merge (~> 1.3.1) + thruster turbo-rails (= 2.0.20) tzinfo-data web-console From 1a3e21ce5eeb9765dc840d108ef32f1889980204 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Thu, 20 Nov 2025 11:20:31 -0800 Subject: [PATCH 07/33] fix node version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c805cf8b..cf3774b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install JavaScript dependencies -ARG NODE_VERSION=22.11.0 +ARG NODE_VERSION=20.10.0 ARG PNPM_VERSION=10.8.0 ENV PATH=/usr/local/node/bin:$PATH RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ From 0c5b66bb30e8bf11865162f9f6d31589d65b1f0b Mon Sep 17 00:00:00 2001 From: Kashiftariq1997 Date: Wed, 11 Feb 2026 17:17:13 +0500 Subject: [PATCH 08/33] add nested sidebar --- app/controllers/docs/sidebar_controller.rb | 6 + app/javascript/controllers/index.js | 3 + .../controllers/nested_sidebar_controller.js | 23 ++ app/views/docs/sidebar.rb | 4 + app/views/docs/sidebar/nested_example.rb | 244 ++++++++++++++++++ config/routes.rb | 1 + 6 files changed, 281 insertions(+) create mode 100644 app/javascript/controllers/nested_sidebar_controller.js create mode 100644 app/views/docs/sidebar/nested_example.rb diff --git a/app/controllers/docs/sidebar_controller.rb b/app/controllers/docs/sidebar_controller.rb index d6ffc469..b0521adc 100644 --- a/app/controllers/docs/sidebar_controller.rb +++ b/app/controllers/docs/sidebar_controller.rb @@ -14,4 +14,10 @@ def inset_example render Views::Docs::Sidebar::InsetExample.new(sidebar_state:) end + + def nested_example + sidebar_state = cookies.fetch(:sidebar_state, "true") == "true" + + render Views::Docs::Sidebar::NestedExample.new(sidebar_state:) + end end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d6..40e89b5d 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -87,3 +87,6 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController) import SidebarMenuController from "./sidebar_menu_controller" application.register("sidebar-menu", SidebarMenuController) + +import NestedSidebarController from "./nested_sidebar_controller" +application.register("nested-sidebar", NestedSidebarController) diff --git a/app/javascript/controllers/nested_sidebar_controller.js b/app/javascript/controllers/nested_sidebar_controller.js new file mode 100644 index 00000000..593dc4f0 --- /dev/null +++ b/app/javascript/controllers/nested_sidebar_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="nested-sidebar" +// Switches content panels when icon rail buttons are clicked +export default class extends Controller { + static targets = ["icon", "panel"] + static values = { active: String } + + select(event) { + this.activeValue = event.currentTarget.dataset.section + } + + activeValueChanged() { + this.iconTargets.forEach((icon) => { + const isActive = icon.dataset.section === this.activeValue + icon.dataset.active = isActive + }) + + this.panelTargets.forEach((panel) => { + panel.hidden = panel.dataset.section !== this.activeValue + }) + } +} diff --git a/app/views/docs/sidebar.rb b/app/views/docs/sidebar.rb index 165872fa..5b418b72 100644 --- a/app/views/docs/sidebar.rb +++ b/app/views/docs/sidebar.rb @@ -37,6 +37,10 @@ def view_template Views::Docs::Sidebar::InsetExample::CODE end + render Docs::VisualCodeExample.new(title: "Nested sidebar", src: "/docs/sidebar/nested", context: self) do + Views::Docs::Sidebar::NestedExample::CODE + end + render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do <<~RUBY Dialog(data: {action: "ruby-ui--dialog:connect->ruby-ui--dialog#open"}) do diff --git a/app/views/docs/sidebar/nested_example.rb b/app/views/docs/sidebar/nested_example.rb new file mode 100644 index 00000000..28b0363a --- /dev/null +++ b/app/views/docs/sidebar/nested_example.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +class Views::Docs::Sidebar::NestedExample < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "\u{1F4CA}"}, + {name: "Movies & TV Shows", emoji: "\u{1F3AC}"}, + {name: "Books & Articles", emoji: "\u{1F4DA}"}, + {name: "Recipes & Meal Planning", emoji: "\u{1F37D}\u{FE0F}"}, + {name: "Travel & Places", emoji: "\u{1F30D}"}, + {name: "Health & Fitness", emoji: "\u{1F3CB}\u{FE0F}"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "\u{1F3E1}"}, + {name: "Work & Projects", emoji: "\u{1F4BC}"}, + {name: "Side Projects", emoji: "\u{1F680}"}, + {name: "Learning & Courses", emoji: "\u{1F4DA}"}, + {name: "Writing & Blogging", emoji: "\u{270D}\u{FE0F}"}, + {name: "Design & Development", emoji: "\u{1F3A8}"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + Sidebar(collapsible: :icon, variant: :inset) do + div(class: "flex h-full", data: {controller: "nested-sidebar", nested_sidebar_active_value: "home"}) do + # Icon rail + div(class: "flex flex-col items-center w-12 shrink-0 border-r border-sidebar-border py-4") do + div(class: "flex flex-col items-center gap-1") do + icon_rail_button("home", active: true) { home_icon() } + icon_rail_button("favorites") { star_icon() } + icon_rail_button("workspaces") { briefcase_icon() } + icon_rail_button("settings") { settings_icon() } + end + div(class: "mt-auto") do + DropdownMenu(options: {strategy: "fixed", placement: "right-end"}) do + button( + type: "button", + class: "flex items-center justify-center size-8 shrink-0 rounded-full bg-sidebar-accent text-sidebar-accent-foreground font-semibold text-sm", + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) { "JD" } + DropdownMenuContent(class: "min-w-56 rounded-lg z-50") do + DropdownMenuLabel do + div(class: "flex flex-col") do + span(class: "truncate font-semibold") { "John Doe" } + span(class: "truncate text-xs text-muted-foreground") { "john@example.com" } + end + end + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Settings" } + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { "Sign out" } + end + end + end + end + + # Content panels (one per icon, toggled by nested-sidebar controller) + div(class: "flex-1 flex flex-col min-w-0 group-data-[collapsible=icon]:hidden") do + # Home panel + div(data: {nested_sidebar_target: "panel", section: "home"}) do + SidebarContent do + SidebarGroup do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon() + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon() + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon() + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + end + end + + # Favorites panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "favorites"}) do + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + end + end + end + end + end + end + + # Workspaces panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "workspaces"}) do + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + end + end + end + end + end + end + + # Settings panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "settings"}) do + SidebarContent do + SidebarGroup do + SidebarGroupContent do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + settings_icon() + span { "Settings" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + message_circle_question_icon() + span { "Help & Support" } + end + end + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def icon_rail_button(section, active: false, &block) + classes = [ + "flex items-center justify-center size-8 rounded-md transition-colors [&>svg]:size-4 [&>svg]:shrink-0", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + "data-[active=false]:text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + ].join(" ") + button( + type: "button", + class: classes, + data: { + nested_sidebar_target: "icon", + section: section, + action: "click->nested-sidebar#select", + active: active + }, + &block + ) + end + + def home_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def star_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a.53.53 0 0 0 .4.29l5.16.756a.53.53 0 0 1 .294.904l-3.733 3.638a.53.53 0 0 0-.153.469l.882 5.14a.53.53 0 0 1-.77.56l-4.614-2.426a.53.53 0 0 0-.494 0L7.14 18.73a.53.53 0 0 1-.77-.56l.882-5.14a.53.53 0 0 0-.153-.47L3.365 8.925a.53.53 0 0 1 .294-.904l5.16-.756a.53.53 0 0 0 .4-.29z") + end + end + + def briefcase_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16") + s.rect(width: "20", height: "14", x: "2", y: "6", rx: "2") + end + end + + def search_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def inbox_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def settings_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def message_circle_question_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z") + s.path(d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3") + s.path(d: "M12 17h.01") + end + end +end diff --git a/config/routes.rb b/config/routes.rb index a6da0ff2..5eaf4f59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,7 @@ get "sidebar", to: "docs#sidebar", as: :docs_sidebar get "sidebar/example", to: "docs/sidebar#example", as: :docs_sidebar_example get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset + get "sidebar/nested", to: "docs/sidebar#nested_example", as: :docs_sidebar_nested get "skeleton", to: "docs#skeleton", as: :docs_skeleton get "switch", to: "docs#switch", as: :docs_switch get "table", to: "docs#table", as: :docs_table From 8429d24a740f6630193f95131c3942833bb600b9 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 00:39:52 -0400 Subject: [PATCH 09/33] Add gemspec and Rails generators for ruby_ui gem - Create ruby_ui.gemspec with gem metadata and dependencies - Add lib/ruby_ui/version.rb defining RubyUI::VERSION - Implement Rails generators: - ruby_ui:component - Generate individual components from app/components/ruby_ui - ruby_ui:component:all - Generate all available components - ruby_ui:install - Install RubyUI with dependencies - ruby_ui:install:docs - Install component documentation - Add JavaScript package management utilities (npm, yarn, pnpm, importmap support) - Include dependencies.yml for component dependency tracking - Add initializer and Tailwind CSS templates for installation --- .../ruby_ui/component/all_generator.rb | 22 +++ lib/generators/ruby_ui/component_generator.rb | 97 +++++++++++ lib/generators/ruby_ui/dependencies.yml | 74 +++++++++ .../ruby_ui/install/docs_generator.rb | 33 ++++ .../ruby_ui/install/install_generator.rb | 83 ++++++++++ .../ruby_ui/install/templates/ruby_ui.rb.erb | 18 +++ .../install/templates/tailwind.css.erb | 153 ++++++++++++++++++ lib/generators/ruby_ui/javascript_utils.rb | 61 +++++++ lib/ruby_ui.rb | 7 + lib/ruby_ui/version.rb | 5 + ruby_ui.gemspec | 19 +++ 11 files changed, 572 insertions(+) create mode 100644 lib/generators/ruby_ui/component/all_generator.rb create mode 100644 lib/generators/ruby_ui/component_generator.rb create mode 100644 lib/generators/ruby_ui/dependencies.yml create mode 100644 lib/generators/ruby_ui/install/docs_generator.rb create mode 100644 lib/generators/ruby_ui/install/install_generator.rb create mode 100644 lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb create mode 100644 lib/generators/ruby_ui/install/templates/tailwind.css.erb create mode 100644 lib/generators/ruby_ui/javascript_utils.rb create mode 100644 lib/ruby_ui.rb create mode 100644 lib/ruby_ui/version.rb create mode 100644 ruby_ui.gemspec diff --git a/lib/generators/ruby_ui/component/all_generator.rb b/lib/generators/ruby_ui/component/all_generator.rb new file mode 100644 index 00000000..a0852fb0 --- /dev/null +++ b/lib/generators/ruby_ui/component/all_generator.rb @@ -0,0 +1,22 @@ +module RubyUI + module Generators + module Component + class AllGenerator < Rails::Generators::Base + namespace 'ruby_ui:component:all' + + source_root File.expand_path('../../../../app/components/ruby_ui', __dir__) + class_option :force, type: :boolean, default: false + + def generate_components + say 'Generating all components...' + + Dir.children(self.class.source_root).each do |folder_name| + next if folder_name.ends_with?('.rb') + + run "bin/rails generate ruby_ui:component #{folder_name} --force #{options['force']}" + end + end + end + end + end +end diff --git a/lib/generators/ruby_ui/component_generator.rb b/lib/generators/ruby_ui/component_generator.rb new file mode 100644 index 00000000..65bc4e81 --- /dev/null +++ b/lib/generators/ruby_ui/component_generator.rb @@ -0,0 +1,97 @@ +require_relative 'javascript_utils' +module RubyUI + module Generators + class ComponentGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils + + namespace 'ruby_ui:component' + + source_root File.expand_path('../../../app/components/ruby_ui', __dir__) + argument :component_name, type: :string, required: true + class_option :force, type: :boolean, default: false + + def generate_component + if component_not_found? + say "Component not found: #{component_name}", :red + exit + end + + say "Generating #{component_name} files..." + end + + def copy_related_component_files + say 'Generating components' + + components_file_paths.each do |file_path| + component_file_name = file_path.split('/').last + copy_file file_path, Rails.root.join('app/components/ruby_ui', component_folder_name, component_file_name), + force: options['force'] + end + end + + def copy_js_files + return if js_controller_file_paths.empty? + + say 'Generating Stimulus controllers' + + js_controller_file_paths.each do |file_path| + controller_file_name = file_path.split('/').last + copy_file file_path, Rails.root.join('app/javascript/controllers/ruby_ui', controller_file_name), + force: options['force'] + end + + # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)` + return if using_importmap? + + say 'Updating Stimulus controllers manifest' + run 'rake stimulus:manifest:update' + end + + def install_dependencies + return if dependencies.blank? + + say 'Installing dependencies' + + install_components_dependencies(dependencies['components']) + install_gems_dependencies(dependencies['gems']) + install_js_packages(dependencies['js_packages']) + end + + private + + def component_not_found? = !Dir.exist?(component_folder_path) + + def component_folder_name = component_name.underscore + + def component_folder_path = File.join(self.class.source_root, component_folder_name) + + def components_file_paths = Dir.glob(File.join(component_folder_path, '*.rb')) + + def js_controller_file_paths = Dir.glob(File.join(component_folder_path, '*.js')) + + def install_components_dependencies(components) + components&.each do |component| + run "bin/rails generate ruby_ui:component #{component} --force #{options['force']}" + end + end + + def install_gems_dependencies(gems) + gems&.each do |ruby_gem| + run "bundle show #{ruby_gem} > /dev/null 2>&1 || bundle add #{ruby_gem}" + end + end + + def install_js_packages(js_packages) + js_packages&.each do |js_package| + install_js_package(js_package) + end + end + + def dependencies + @dependencies ||= YAML.load_file(File.join(__dir__, 'dependencies.yml')).freeze + + @dependencies[component_folder_name] + end + end + end +end diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml new file mode 100644 index 00000000..31ee03a5 --- /dev/null +++ b/lib/generators/ruby_ui/dependencies.yml @@ -0,0 +1,74 @@ +accordion: + js_packages: + - "motion" + +alert_dialog: + components: + - "Button" + +calendar: + js_packages: + - "mustache" + +carousel: + js_packages: + - "embla-carousel" + +chart: + js_packages: + - "chart.js" + +clipboard: + js_packages: + - "@floating-ui/dom" + +codeblock: + components: + - "Button" + - "Clipboard" + + gems: + - "rouge" + +combobox: + js_packages: + - "@floating-ui/dom" + +command: + js_packages: + - "fuse.js" + +context_menu: + js_packages: + - "tippy.js" + +dropdown_menu: + js_packages: + - "@floating-ui/dom" + +hover_card: + js_packages: + - "tippy.js" + +masked_input: + components: + - "Input" + + js_packages: + - "maska" + +pagination: + components: + - "Button" + +popover: + js_packages: + - "@floating-ui/dom" + +select: + js_packages: + - "@floating-ui/dom" + +tooltip: + js_packages: + - "@floating-ui/dom" diff --git a/lib/generators/ruby_ui/install/docs_generator.rb b/lib/generators/ruby_ui/install/docs_generator.rb new file mode 100644 index 00000000..b711fae6 --- /dev/null +++ b/lib/generators/ruby_ui/install/docs_generator.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails/generators' + +module RubyUI + module Generators + module Install + class DocsGenerator < Rails::Generators::Base + namespace 'ruby_ui:install:docs' + source_root File.expand_path('../../../../app/components/ruby_ui', __dir__) + class_option :force, type: :boolean, default: false + + def copy_docs_files + say 'Installing RubyUI documentation files...' + + docs_file_paths.each do |source_path| + dest_filename = File.basename(source_path).sub('_docs', '') + copy_file source_path, Rails.root.join('app/views/docs', dest_filename), force: options['force'] + end + + say '' + say 'Documentation installed to app/views/docs/', :green + end + + private + + def docs_file_paths + Dir.glob(File.join(self.class.source_root, '*', '*_docs.rb')) + end + end + end + end +end diff --git a/lib/generators/ruby_ui/install/install_generator.rb b/lib/generators/ruby_ui/install/install_generator.rb new file mode 100644 index 00000000..3d6d7adc --- /dev/null +++ b/lib/generators/ruby_ui/install/install_generator.rb @@ -0,0 +1,83 @@ +require 'rails/generators' +require_relative '../javascript_utils' + +module RubyUI + module Generators + class InstallGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils + + namespace 'ruby_ui:install' + + source_root File.expand_path('templates', __dir__) + + def install_phlex_rails + say 'Checking for phlex-rails' + + if gem_installed?('phlex-rails') + say 'phlex-rails is already installed', :green + else + say 'Adding phlex-rails to Gemfile' + run %(bundle add phlex-rails) + + say 'Generating phlex-rails structure' + run 'bin/rails generate phlex:install' + end + end + + def install_tailwind_merge + say 'Checking for tailwind_merge' + + if gem_installed?('tailwind_merge') + say 'tailwind_merge is already installed', :green + else + say 'Adding phlex-rails to Gemfile' + run %(bundle add tailwind_merge) + end + end + + def install_ruby_ui_initializer + say 'Creating RubyUI initializer' + template 'ruby_ui.rb.erb', Rails.root.join('config/initializers/ruby_ui.rb') + end + + def add_ruby_ui_module_to_components_base + say 'Adding RubyUI Kit to Components::Base' + insert_into_file Rails.root.join('app/components/base.rb'), after: 'class Components::Base < Phlex::HTML' do + "\n include RubyUI" + end + end + + def add_tailwind_css + say 'Adding Tailwind css' + + css_path = if using_importmap? + Rails.root.join('app/assets/tailwind/application.css') + else + Rails.root.join('app/assets/stylesheets/application.tailwind.css') + end + + template 'tailwind.css.erb', css_path + end + + def install_tailwind_plugins + say 'Installing tw-animate-css plugin' + install_js_package('tw-animate-css') + end + + def add_ruby_ui_base + say 'Adding RubyUI::Base component' + template '../../../../app/components/ruby_ui/base.rb', Rails.root.join('app/components/ruby_ui/base.rb') + end + + private + + def gem_installed?(name) + Gem::Specification.find_all_by_name(name).any? + end + + def using_tailwindcss_rails_gem? + File.exist?(Rails.root.join('app/assets/tailwind/application.css')) + end + end + end +end diff --git a/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb b/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb new file mode 100644 index 00000000..a5f251f1 --- /dev/null +++ b/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + extend Phlex::Kit +end + +# Allow using RubyUI instead RubyUi +Rails.autoloaders.main.inflector.inflect( + "ruby_ui" => "RubyUI" +) + +# Allow using RubyUI::ComponentName instead Components::RubyUI::ComponentName +Rails.autoloaders.main.push_dir( + Rails.root.join("app/components/ruby_ui"), namespace: RubyUI +) + +# Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName +Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) diff --git a/lib/generators/ruby_ui/install/templates/tailwind.css.erb b/lib/generators/ruby_ui/install/templates/tailwind.css.erb new file mode 100644 index 00000000..dc7fa403 --- /dev/null +++ b/lib/generators/ruby_ui/install/templates/tailwind.css.erb @@ -0,0 +1,153 @@ +@import "tailwindcss"; + +<% if using_importmap? %> +@import "../../../vendor/javascript/tw-animate-css.js"; +<% else %> +@import "tw-animate-css"; +<% end %> + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* ruby_ui specific */ + --warning: hsl(38 92% 50%); + --warning-foreground: hsl(0 0% 100%); + --success: hsl(87 100% 37%); + --success-foreground: hsl(0 0% 100%); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); + + /* ruby_ui specific */ + --warning: hsl(38 92% 50%); + --warning-foreground: hsl(0 0% 100%); + --success: hsl(84 81% 44%); + --success-foreground: hsl(0 0% 100%); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* ruby_ui specific */ + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); +} + +/* Container settings */ +@utility container { + margin-inline: auto; + padding-inline: 2rem; + max-width: 1400px; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/lib/generators/ruby_ui/javascript_utils.rb b/lib/generators/ruby_ui/javascript_utils.rb new file mode 100644 index 00000000..0aaa132c --- /dev/null +++ b/lib/generators/ruby_ui/javascript_utils.rb @@ -0,0 +1,61 @@ +module RubyUI + module Generators + module JavascriptUtils + def install_js_package(package) + if using_importmap? + pin_with_importmap(package) + elsif using_yarn? + run "yarn add #{package}" + elsif using_npm? + run "npm install #{package}" + elsif using_pnpm? + run "pnpm install #{package}" + else + say "Could not detect the package manager, you need to install '#{package}' manually", :red + end + end + + def pin_with_importmap(package) + case package + when 'motion' + pin_motion + when 'tippy.js' + pin_tippy_js + else + run "bin/importmap pin #{package}" + end + end + + def using_importmap? + File.exist?(Rails.root.join('config/importmap.rb')) && File.exist?(Rails.root.join('bin/importmap')) + end + + def using_npm? = File.exist?(Rails.root.join('package-lock.json')) + + def using_pnpm? = File.exist?(Rails.root.join('pnpm-lock.yaml')) + + def using_yarn? = File.exist?(Rails.root.join('yarn.lock')) + + def pin_motion + say <<~TEXT + WARNING: Installing motion from CDN because `bin/importmap pin motion` doesn't download the correct file. + TEXT + + inject_into_file Rails.root.join('config/importmap.rb'), <<~RUBY + pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@11.11.17/+esm"\n + RUBY + end + + def pin_tippy_js + say <<~TEXT + WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file. + TEXT + + inject_into_file Rails.root.join('config/importmap.rb'), <<~RUBY + pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" + pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n + RUBY + end + end + end +end diff --git a/lib/ruby_ui.rb b/lib/ruby_ui.rb new file mode 100644 index 00000000..d44af49a --- /dev/null +++ b/lib/ruby_ui.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require_relative 'ruby_ui/version' + +module RubyUI + class Error < StandardError; end +end diff --git a/lib/ruby_ui/version.rb b/lib/ruby_ui/version.rb new file mode 100644 index 00000000..98633e80 --- /dev/null +++ b/lib/ruby_ui/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module RubyUI + VERSION = '0.1.0' +end diff --git a/ruby_ui.gemspec b/ruby_ui.gemspec new file mode 100644 index 00000000..ee2096ee --- /dev/null +++ b/ruby_ui.gemspec @@ -0,0 +1,19 @@ +require_relative 'lib/ruby_ui/version' + +Gem::Specification.new do |s| + s.name = 'seth_ruby_ui' + s.version = RubyUI::VERSION + s.summary = 'RubyUI is a UI Component Library built on Phlex.' + s.description = 'RubyUI is a UI Component Library for Ruby developers. Built on top of the Phlex framework.' + s.authors = ['Seth Horsley'] + s.files = Dir['README.md', 'LICENSE.txt', 'lib/**/*', 'app/components/ruby_ui/**/*'] + s.require_paths = ['lib'] + s.homepage = 'https://rubygems.org/gems/ruby_ui' + s.license = 'MIT' + + s.required_ruby_version = '>= 3.2' + + s.add_dependency 'phlex', '>= 2.0' + s.add_dependency 'rouge' + s.add_dependency 'tailwind_merge', '>= 0.12' +end From 220424ce13029ea29ab81e8e42750d4226e22de7 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 00:46:23 -0400 Subject: [PATCH 10/33] update ruby --- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 2aa51319..1454f6ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +4.0.1 diff --git a/Gemfile b/Gemfile index 17257075..d6e2bbe6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby "3.4.7" +ruby "4.0.1" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "8.1.2" diff --git a/Gemfile.lock b/Gemfile.lock index 15cde4d8..88ed4217 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -353,7 +353,7 @@ DEPENDENCIES web-console RUBY VERSION - ruby 3.4.7p58 + ruby 4.0.1p0 BUNDLED WITH 2.6.4 From 5b7af722fe31d4e0723c930be71771fdfbadea23 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 00:52:45 -0400 Subject: [PATCH 11/33] =?UTF-8?q?=F0=9F=8D=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/docs/visual_code_example.rb | 35 ++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..6cefb73e 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -13,14 +13,19 @@ def self.reset_collected_code @@collected_code = [] end - def initialize(title: nil, description: nil, src: nil, context: nil) + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code @title = title @description = description @src = src @context = context + @type = type + @content = content + @content_attributes = content_attributes end def view_template(&) + # @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) @display_code = CGI.unescapeHTML(capture(&)) @@collected_code << @display_code @@ -55,7 +60,7 @@ def render_header def render_tab_triggers TabsList do render_tab_trigger("preview", "Preview", method(:eye_icon)) - render_tab_trigger("code", "Code", method(:code_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) if @type == :component end end @@ -72,15 +77,25 @@ def render_tab_contents(&) end def render_preview_tab(&block) - return iframe_preview if @src + block_class_name = @content.to_s + + return iframe_preview(block_class_name) if @type == :block raw_preview end - def iframe_preview - div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do + def iframe_preview(block_name) + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border") do div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do - iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) + if @content + iframe(src: render_block_path(id: block_name, attributes: @content_attributes), class: "size-full") + else + iframe(srcdoc: safe("
You cannot render a ruby block for a block preview
"), class: "size-full") + # TODO + # decoded_code = CGI.unescapeHTML(@display_code) + # html_content = render_block_to_html(decoded_code) + # iframe(srcdoc: safe(html_content), class: "size-full") + end end end end @@ -100,6 +115,14 @@ def render_code_tab end end + def render_block_to_html(code) + # Extract the component from "render ComponentName.new" pattern + # and evaluate it to generate standalone HTML + component_code = code.strip.sub(/^render\s+/, '') + component = eval(component_code) + component.call + end + def eye_icon svg( xmlns: "http://www.w3.org/2000/svg", From 684fa6e1f78f113f830222fdf191c5a4b2ab3da2 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:03:00 -0400 Subject: [PATCH 12/33] update ruby --- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 2aa51319..1454f6ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +4.0.1 diff --git a/Gemfile b/Gemfile index 17257075..d6e2bbe6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby "3.4.7" +ruby "4.0.1" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "8.1.2" diff --git a/Gemfile.lock b/Gemfile.lock index 15cde4d8..88ed4217 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -353,7 +353,7 @@ DEPENDENCIES web-console RUBY VERSION - ruby 3.4.7p58 + ruby 4.0.1p0 BUNDLED WITH 2.6.4 From 9afcb2fcebdc56fab2186603b0071c216f381abc Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:29:35 -0400 Subject: [PATCH 13/33] =?UTF-8?q?=E2=9A=BE=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30eb8461..ed856f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,6 @@ jobs: - name: Build and run dev container task uses: devcontainers/ci@v0.3 with: - imageName: ghcr.io/ruby-ui/web-devcontainer - cacheFrom: ghcr.io/ruby-ui/web-devcontainer + imageName: ghcr.io/seth-ruby-ui/web-devcontainer + cacheFrom: ghcr.io/seth-ruby-ui/web-devcontainer push: always From 9e45f1ac1e96e9f468d6653892fd2d6dcc58b0d1 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:41:42 -0400 Subject: [PATCH 14/33] standard rb --- Gemfile | 4 +- Gemfile.lock | 13 +++-- .../ruby_ui/component/all_generator.rb | 10 ++-- lib/generators/ruby_ui/component_generator.rb | 42 +++++++------- .../ruby_ui/install/docs_generator.rb | 18 +++--- .../ruby_ui/install/install_generator.rb | 58 +++++++++---------- lib/generators/ruby_ui/javascript_utils.rb | 16 ++--- lib/ruby_ui.rb | 2 +- lib/ruby_ui/version.rb | 2 +- ruby_ui.gemspec | 26 ++++----- 10 files changed, 96 insertions(+), 95 deletions(-) diff --git a/Gemfile b/Gemfile index d6e2bbe6..59e57f67 100644 --- a/Gemfile +++ b/Gemfile @@ -73,8 +73,8 @@ end gem "phlex", github: "phlex-ruby/phlex" gem "phlex-rails", github: "phlex-ruby/phlex-rails" -gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false -# gem "ruby_ui", path: "../ruby_ui" +# gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false +gem "seth_ruby_ui", path: "." gem "pry", "0.16.0" diff --git a/Gemfile.lock b/Gemfile.lock index 88ed4217..90664123 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,12 +12,13 @@ GIT specs: phlex (2.0.2) -GIT - remote: https://github.com/ruby-ui/ruby_ui.git - revision: f88242965bcb682a51223b07fa9019cb6e3ddbf6 - branch: main +PATH + remote: . specs: - ruby_ui (1.1.0) + seth_ruby_ui (0.1.0) + phlex (>= 2.0) + rouge + tailwind_merge (>= 0.12) GEM remote: https://rubygems.org/ @@ -342,8 +343,8 @@ DEPENDENCIES puma (= 7.2.0) rails (= 8.1.2) rouge (~> 4.7) - ruby_ui! selenium-webdriver + seth_ruby_ui! sqlite3 (= 2.9.0) standard stimulus-rails (= 1.3.4) diff --git a/lib/generators/ruby_ui/component/all_generator.rb b/lib/generators/ruby_ui/component/all_generator.rb index a0852fb0..d8d5c274 100644 --- a/lib/generators/ruby_ui/component/all_generator.rb +++ b/lib/generators/ruby_ui/component/all_generator.rb @@ -2,18 +2,18 @@ module RubyUI module Generators module Component class AllGenerator < Rails::Generators::Base - namespace 'ruby_ui:component:all' + namespace "ruby_ui:component:all" - source_root File.expand_path('../../../../app/components/ruby_ui', __dir__) + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) class_option :force, type: :boolean, default: false def generate_components - say 'Generating all components...' + say "Generating all components..." Dir.children(self.class.source_root).each do |folder_name| - next if folder_name.ends_with?('.rb') + next if folder_name.ends_with?(".rb") - run "bin/rails generate ruby_ui:component #{folder_name} --force #{options['force']}" + run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}" end end end diff --git a/lib/generators/ruby_ui/component_generator.rb b/lib/generators/ruby_ui/component_generator.rb index 65bc4e81..9b4fa928 100644 --- a/lib/generators/ruby_ui/component_generator.rb +++ b/lib/generators/ruby_ui/component_generator.rb @@ -1,12 +1,12 @@ -require_relative 'javascript_utils' +require_relative "javascript_utils" module RubyUI module Generators class ComponentGenerator < Rails::Generators::Base include RubyUI::Generators::JavascriptUtils - namespace 'ruby_ui:component' + namespace "ruby_ui:component" - source_root File.expand_path('../../../app/components/ruby_ui', __dir__) + source_root File.expand_path("../../../app/components/ruby_ui", __dir__) argument :component_name, type: :string, required: true class_option :force, type: :boolean, default: false @@ -20,41 +20,41 @@ def generate_component end def copy_related_component_files - say 'Generating components' + say "Generating components" components_file_paths.each do |file_path| - component_file_name = file_path.split('/').last - copy_file file_path, Rails.root.join('app/components/ruby_ui', component_folder_name, component_file_name), - force: options['force'] + component_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), + force: options["force"] end end def copy_js_files return if js_controller_file_paths.empty? - say 'Generating Stimulus controllers' + say "Generating Stimulus controllers" js_controller_file_paths.each do |file_path| - controller_file_name = file_path.split('/').last - copy_file file_path, Rails.root.join('app/javascript/controllers/ruby_ui', controller_file_name), - force: options['force'] + controller_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), + force: options["force"] end # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)` return if using_importmap? - say 'Updating Stimulus controllers manifest' - run 'rake stimulus:manifest:update' + say "Updating Stimulus controllers manifest" + run "rake stimulus:manifest:update" end def install_dependencies return if dependencies.blank? - say 'Installing dependencies' + say "Installing dependencies" - install_components_dependencies(dependencies['components']) - install_gems_dependencies(dependencies['gems']) - install_js_packages(dependencies['js_packages']) + install_components_dependencies(dependencies["components"]) + install_gems_dependencies(dependencies["gems"]) + install_js_packages(dependencies["js_packages"]) end private @@ -65,13 +65,13 @@ def component_folder_name = component_name.underscore def component_folder_path = File.join(self.class.source_root, component_folder_name) - def components_file_paths = Dir.glob(File.join(component_folder_path, '*.rb')) + def components_file_paths = Dir.glob(File.join(component_folder_path, "*.rb")) - def js_controller_file_paths = Dir.glob(File.join(component_folder_path, '*.js')) + def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js")) def install_components_dependencies(components) components&.each do |component| - run "bin/rails generate ruby_ui:component #{component} --force #{options['force']}" + run "bin/rails generate ruby_ui:component #{component} --force #{options["force"]}" end end @@ -88,7 +88,7 @@ def install_js_packages(js_packages) end def dependencies - @dependencies ||= YAML.load_file(File.join(__dir__, 'dependencies.yml')).freeze + @dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze @dependencies[component_folder_name] end diff --git a/lib/generators/ruby_ui/install/docs_generator.rb b/lib/generators/ruby_ui/install/docs_generator.rb index b711fae6..27390ca8 100644 --- a/lib/generators/ruby_ui/install/docs_generator.rb +++ b/lib/generators/ruby_ui/install/docs_generator.rb @@ -1,31 +1,31 @@ # frozen_string_literal: true -require 'rails/generators' +require "rails/generators" module RubyUI module Generators module Install class DocsGenerator < Rails::Generators::Base - namespace 'ruby_ui:install:docs' - source_root File.expand_path('../../../../app/components/ruby_ui', __dir__) + namespace "ruby_ui:install:docs" + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) class_option :force, type: :boolean, default: false def copy_docs_files - say 'Installing RubyUI documentation files...' + say "Installing RubyUI documentation files..." docs_file_paths.each do |source_path| - dest_filename = File.basename(source_path).sub('_docs', '') - copy_file source_path, Rails.root.join('app/views/docs', dest_filename), force: options['force'] + dest_filename = File.basename(source_path).sub("_docs", "") + copy_file source_path, Rails.root.join("app/views/docs", dest_filename), force: options["force"] end - say '' - say 'Documentation installed to app/views/docs/', :green + say "" + say "Documentation installed to app/views/docs/", :green end private def docs_file_paths - Dir.glob(File.join(self.class.source_root, '*', '*_docs.rb')) + Dir.glob(File.join(self.class.source_root, "*", "*_docs.rb")) end end end diff --git a/lib/generators/ruby_ui/install/install_generator.rb b/lib/generators/ruby_ui/install/install_generator.rb index 3d6d7adc..3a85e727 100644 --- a/lib/generators/ruby_ui/install/install_generator.rb +++ b/lib/generators/ruby_ui/install/install_generator.rb @@ -1,72 +1,72 @@ -require 'rails/generators' -require_relative '../javascript_utils' +require "rails/generators" +require_relative "../javascript_utils" module RubyUI module Generators class InstallGenerator < Rails::Generators::Base include RubyUI::Generators::JavascriptUtils - namespace 'ruby_ui:install' + namespace "ruby_ui:install" - source_root File.expand_path('templates', __dir__) + source_root File.expand_path("templates", __dir__) def install_phlex_rails - say 'Checking for phlex-rails' + say "Checking for phlex-rails" - if gem_installed?('phlex-rails') - say 'phlex-rails is already installed', :green + if gem_installed?("phlex-rails") + say "phlex-rails is already installed", :green else - say 'Adding phlex-rails to Gemfile' + say "Adding phlex-rails to Gemfile" run %(bundle add phlex-rails) - say 'Generating phlex-rails structure' - run 'bin/rails generate phlex:install' + say "Generating phlex-rails structure" + run "bin/rails generate phlex:install" end end def install_tailwind_merge - say 'Checking for tailwind_merge' + say "Checking for tailwind_merge" - if gem_installed?('tailwind_merge') - say 'tailwind_merge is already installed', :green + if gem_installed?("tailwind_merge") + say "tailwind_merge is already installed", :green else - say 'Adding phlex-rails to Gemfile' + say "Adding phlex-rails to Gemfile" run %(bundle add tailwind_merge) end end def install_ruby_ui_initializer - say 'Creating RubyUI initializer' - template 'ruby_ui.rb.erb', Rails.root.join('config/initializers/ruby_ui.rb') + say "Creating RubyUI initializer" + template "ruby_ui.rb.erb", Rails.root.join("config/initializers/ruby_ui.rb") end def add_ruby_ui_module_to_components_base - say 'Adding RubyUI Kit to Components::Base' - insert_into_file Rails.root.join('app/components/base.rb'), after: 'class Components::Base < Phlex::HTML' do + say "Adding RubyUI Kit to Components::Base" + insert_into_file Rails.root.join("app/components/base.rb"), after: "class Components::Base < Phlex::HTML" do "\n include RubyUI" end end def add_tailwind_css - say 'Adding Tailwind css' + say "Adding Tailwind css" css_path = if using_importmap? - Rails.root.join('app/assets/tailwind/application.css') - else - Rails.root.join('app/assets/stylesheets/application.tailwind.css') - end + Rails.root.join("app/assets/tailwind/application.css") + else + Rails.root.join("app/assets/stylesheets/application.tailwind.css") + end - template 'tailwind.css.erb', css_path + template "tailwind.css.erb", css_path end def install_tailwind_plugins - say 'Installing tw-animate-css plugin' - install_js_package('tw-animate-css') + say "Installing tw-animate-css plugin" + install_js_package("tw-animate-css") end def add_ruby_ui_base - say 'Adding RubyUI::Base component' - template '../../../../app/components/ruby_ui/base.rb', Rails.root.join('app/components/ruby_ui/base.rb') + say "Adding RubyUI::Base component" + template "../../../../app/components/ruby_ui/base.rb", Rails.root.join("app/components/ruby_ui/base.rb") end private @@ -76,7 +76,7 @@ def gem_installed?(name) end def using_tailwindcss_rails_gem? - File.exist?(Rails.root.join('app/assets/tailwind/application.css')) + File.exist?(Rails.root.join("app/assets/tailwind/application.css")) end end end diff --git a/lib/generators/ruby_ui/javascript_utils.rb b/lib/generators/ruby_ui/javascript_utils.rb index 0aaa132c..4c17a80c 100644 --- a/lib/generators/ruby_ui/javascript_utils.rb +++ b/lib/generators/ruby_ui/javascript_utils.rb @@ -17,9 +17,9 @@ def install_js_package(package) def pin_with_importmap(package) case package - when 'motion' + when "motion" pin_motion - when 'tippy.js' + when "tippy.js" pin_tippy_js else run "bin/importmap pin #{package}" @@ -27,21 +27,21 @@ def pin_with_importmap(package) end def using_importmap? - File.exist?(Rails.root.join('config/importmap.rb')) && File.exist?(Rails.root.join('bin/importmap')) + File.exist?(Rails.root.join("config/importmap.rb")) && File.exist?(Rails.root.join("bin/importmap")) end - def using_npm? = File.exist?(Rails.root.join('package-lock.json')) + def using_npm? = File.exist?(Rails.root.join("package-lock.json")) - def using_pnpm? = File.exist?(Rails.root.join('pnpm-lock.yaml')) + def using_pnpm? = File.exist?(Rails.root.join("pnpm-lock.yaml")) - def using_yarn? = File.exist?(Rails.root.join('yarn.lock')) + def using_yarn? = File.exist?(Rails.root.join("yarn.lock")) def pin_motion say <<~TEXT WARNING: Installing motion from CDN because `bin/importmap pin motion` doesn't download the correct file. TEXT - inject_into_file Rails.root.join('config/importmap.rb'), <<~RUBY + inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@11.11.17/+esm"\n RUBY end @@ -51,7 +51,7 @@ def pin_tippy_js WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file. TEXT - inject_into_file Rails.root.join('config/importmap.rb'), <<~RUBY + inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n RUBY diff --git a/lib/ruby_ui.rb b/lib/ruby_ui.rb index d44af49a..206b4048 100644 --- a/lib/ruby_ui.rb +++ b/lib/ruby_ui.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'ruby_ui/version' +require_relative "ruby_ui/version" module RubyUI class Error < StandardError; end diff --git a/lib/ruby_ui/version.rb b/lib/ruby_ui/version.rb index 98633e80..6ff82794 100644 --- a/lib/ruby_ui/version.rb +++ b/lib/ruby_ui/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RubyUI - VERSION = '0.1.0' + VERSION = "0.1.0" end diff --git a/ruby_ui.gemspec b/ruby_ui.gemspec index ee2096ee..4d21612e 100644 --- a/ruby_ui.gemspec +++ b/ruby_ui.gemspec @@ -1,19 +1,19 @@ -require_relative 'lib/ruby_ui/version' +require_relative "lib/ruby_ui/version" Gem::Specification.new do |s| - s.name = 'seth_ruby_ui' + s.name = "seth_ruby_ui" s.version = RubyUI::VERSION - s.summary = 'RubyUI is a UI Component Library built on Phlex.' - s.description = 'RubyUI is a UI Component Library for Ruby developers. Built on top of the Phlex framework.' - s.authors = ['Seth Horsley'] - s.files = Dir['README.md', 'LICENSE.txt', 'lib/**/*', 'app/components/ruby_ui/**/*'] - s.require_paths = ['lib'] - s.homepage = 'https://rubygems.org/gems/ruby_ui' - s.license = 'MIT' + s.summary = "RubyUI is a UI Component Library built on Phlex." + s.description = "RubyUI is a UI Component Library for Ruby developers. Built on top of the Phlex framework." + s.authors = ["Seth Horsley"] + s.files = Dir["README.md", "LICENSE.txt", "lib/**/*", "app/components/ruby_ui/**/*"] + s.require_paths = ["lib"] + s.homepage = "https://rubygems.org/gems/ruby_ui" + s.license = "MIT" - s.required_ruby_version = '>= 3.2' + s.required_ruby_version = ">= 3.2" - s.add_dependency 'phlex', '>= 2.0' - s.add_dependency 'rouge' - s.add_dependency 'tailwind_merge', '>= 0.12' + s.add_dependency "phlex", ">= 2.0" + s.add_dependency "rouge" + s.add_dependency "tailwind_merge", ">= 0.12" end From d864489f862080038ba3eac05a356f8caba67710 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:48:24 -0400 Subject: [PATCH 15/33] =?UTF-8?q?=F0=9F=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.md | 137 +++++++++++++++++++++++++++++++++++++++++ Gemfile.lock | 2 +- lib/ruby_ui.rb | 4 +- lib/ruby_ui/version.rb | 5 -- ruby_ui.gemspec | 2 +- 5 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 AGENT.md delete mode 100644 lib/ruby_ui/version.rb diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..376bb108 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,137 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Jumpstart Pro Rails is a commercial multi-tenant SaaS starter application built with Rails 8. It provides subscription billing, team management, authentication, and modern Rails patterns for building subscription-based web applications. + +## Development Commands + +```bash +# Initial setup +bin/setup # Install dependencies and setup database + +# Development server +bin/dev # Start development server with Overmind (includes Rails server, asset watching) +bin/rails server # Standard Rails server only + +# Database +bin/rails db:prepare # Setup database (creates, migrates, seeds) +bin/rails db:migrate # Run migrations +bin/rails db:seed # Seed database + +# Testing +bin/rails test # Run test suite (Minitest) +bin/rails test:system # Run system tests (Capybara + Selenium) + +# Code quality +bin/rubocop # Run RuboCop linter (configured in .rubocop.yml) +bin/rubocop -a # Auto-fix RuboCop issues + +# Background jobs +bin/jobs # Start SolidQueue worker (if using SolidQueue) +bundle exec sidekiq # Start Sidekiq worker (if using Sidekiq) +``` + +## Architecture + +### Multi-tenancy System +- **Account-based tenancy**: Users belong to Accounts (personal or team) +- **AccountUser model**: Join table managing user-account relationships with roles +- **Current account switching**: Users can switch between accounts via `switch_account(account)` +- **Authorization**: Pundit policies scope data by current account + +### Modular Models +Models use Ruby modules for organization: +```ruby +# app/models/user.rb +class User < ApplicationRecord + include Accounts, Agreements, Authenticatable, Mentions, Notifiable, Searchable, Theme +end + +# app/models/account.rb +class Account < ApplicationRecord + include Billing, Domains, Transfer, Types +end +``` + +### Jumpstart Configuration System +- **Dynamic configuration**: `config/jumpstart.yml` controls enabled features +- **Runtime gem loading**: `Gemfile.jumpstart` loads gems based on configuration +- **Feature toggles**: Payment processors, integrations, background jobs, etc. +- Access via `Jumpstart.config.payment_processors`, `Jumpstart.config.stripe?`, etc. + +### Payment Architecture +- **Pay gem (~11.0)**: Unified interface for multiple payment processors +- **Processor-agnostic**: Stripe, Paddle, Braintree, PayPal, Lemon Squeezy support +- **Per-seat billing**: Team accounts with usage-based pricing +- **Subscription management**: In `app/models/account/billing.rb` +- **Email delivery**: Mailgun, Mailpace, Postmark, and Resend use API gems instead of SMTP +- **API client errors**: Raise `UnprocessableContent` for 422 responses (rfc9110) + +## Technology Stack + +- **Rails 8** with Hotwire (Turbo + Stimulus) and Hotwire Native +- **PostgreSQL** (primary), **SolidQueue** (jobs), **SolidCache** (cache), **SolidCable** (websockets) +- **Import Maps** for JavaScript (no Node.js dependency) +- **TailwindCSS v4** via tailwindcss-rails gem +- **Devise** for authentication with custom extensions +- **Pundit** for authorization +- **Minitest** for testing with parallel execution + +## Testing + +- **Minitest** with fixtures in `test/fixtures/` +- **System tests** use Capybara with Selenium WebDriver +- **Test parallelization** enabled via `parallelize(workers: :number_of_processors)` +- **WebMock** configured to disable external HTTP requests +- **Test database** reset between runs + +## Routes Organization + +Routes are modularized in `config/routes/`: +- `accounts.rb` - Account management, switching, invitations +- `billing.rb` - Subscription, payment, receipt routes +- `users.rb` - User profile, settings, authentication +- `api.rb` - API v1 endpoints with JWT authentication + +## Key Directories + +- `app/controllers/accounts/` - Account-scoped controllers +- `app/models/concerns/` - Shared model modules +- `app/policies/` - Pundit authorization policies +- `lib/jumpstart/` - Core Jumpstart engine and configuration +- `config/routes/` - Modular route definitions +- `app/components/` - View components for reusable UI + +## Development Notes + +- **Current account** available via `current_account` helper in controllers/views +- **Account switching** via `switch_account(account)` in tests +- **Billing features** conditionally loaded based on `Jumpstart.config.payments_enabled?` +- **Background jobs** configurable between SolidQueue and Sidekiq +- **Multi-database** setup with separate databases for cache, jobs, and cable + +## Important Conventions + +### Do NOT +- Commit, push, or create PRs unless explicitly directed +- Use hash syntax for enums (use positional arguments) +- Skip tenant scoping for multi-tenant models + +### Git Operations - ALWAYS Confirm First +Before executing any permanent git or GitHub operation, **always ask for user confirmation**. This includes: +- `git commit` - Show the commit message and files to be committed +- `git push` - Show what will be pushed and to which remote/branch +- `gh pr create` - Show the PR title, description, and target branch +- `git reset --hard`, `git rebase`, or any destructive operations + +**Never run these commands without explicit user approval**, even if the user asked to "commit and push" or "create a PR". Always show what will happen first and wait for confirmation. + +### Do +- Use fixtures for test data +- Add YARD documentation to public methods +- Make UI responsive and dark-mode compatible +- Use Hotwire (Turbo + Stimulus) for JavaScript functionality +- Use the ruby ui components and app components instead of creating new ones. diff --git a/Gemfile.lock b/Gemfile.lock index 90664123..924da2e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -357,4 +357,4 @@ RUBY VERSION ruby 4.0.1p0 BUNDLED WITH - 2.6.4 + 4.0.7 diff --git a/lib/ruby_ui.rb b/lib/ruby_ui.rb index 206b4048..491be44a 100644 --- a/lib/ruby_ui.rb +++ b/lib/ruby_ui.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "ruby_ui/version" - module RubyUI + VERSION = "0.1.0" + class Error < StandardError; end end diff --git a/lib/ruby_ui/version.rb b/lib/ruby_ui/version.rb deleted file mode 100644 index 6ff82794..00000000 --- a/lib/ruby_ui/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - VERSION = "0.1.0" -end diff --git a/ruby_ui.gemspec b/ruby_ui.gemspec index 4d21612e..7221853c 100644 --- a/ruby_ui.gemspec +++ b/ruby_ui.gemspec @@ -1,4 +1,4 @@ -require_relative "lib/ruby_ui/version" +require_relative "lib/ruby_ui" Gem::Specification.new do |s| s.name = "seth_ruby_ui" From 95edb702a60fe8d101893c288c41ee3e9b509db6 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:51:20 -0400 Subject: [PATCH 16/33] =?UTF-8?q?=F0=9F=A5=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/000-cursor-rules.mdc | 113 ++++++++++++ .cursor/rules/1000-rails-general.mdc | 87 +++++++++ .cursor/rules/1001-rails-controllers.mdc | 82 +++++++++ .cursor/rules/1002-rails-models.mdc | 105 +++++++++++ .cursor/rules/1003-rails-views.mdc | 107 +++++++++++ .cursor/rules/1004-javascript-stimulus.mdc | 90 +++++++++ .cursor/rules/1005-service-objects.mdc | 129 +++++++++++++ .cursor/rules/1006-testing.mdc | 71 +++++++ .cursor/rules/1007-tailwindcss.mdc | 203 +++++++++++++++++++++ app/helpers/application_helper.rb | 2 +- app/lib/ruby_ui/file_manager.rb | 2 +- 11 files changed, 989 insertions(+), 2 deletions(-) create mode 100644 .cursor/rules/000-cursor-rules.mdc create mode 100644 .cursor/rules/1000-rails-general.mdc create mode 100644 .cursor/rules/1001-rails-controllers.mdc create mode 100644 .cursor/rules/1002-rails-models.mdc create mode 100644 .cursor/rules/1003-rails-views.mdc create mode 100644 .cursor/rules/1004-javascript-stimulus.mdc create mode 100644 .cursor/rules/1005-service-objects.mdc create mode 100644 .cursor/rules/1006-testing.mdc create mode 100644 .cursor/rules/1007-tailwindcss.mdc diff --git a/.cursor/rules/000-cursor-rules.mdc b/.cursor/rules/000-cursor-rules.mdc new file mode 100644 index 00000000..084e8ea5 --- /dev/null +++ b/.cursor/rules/000-cursor-rules.mdc @@ -0,0 +1,113 @@ +--- +description: Use ALWAYS when asked to CREATE A RULE or UPDATE A RULE or taught a lesson from the user that should be retained as a new rule for Cursor +globs: [".cursor/rules/*.mdc"] +--- +# Cursor Rules Format +## Core Structure + +```mdc +--- +description: ACTION when TRIGGER to OUTCOME +globs: *.mdc +--- + +# Rule Title + +## Context +- When to apply this rule +- Prerequisites or conditions + +## Requirements +- Concise, actionable items +- Each requirement must be testable + +## Examples + +Good concise example with explanation + + + +Invalid concise example with explanation + +``` + +## File Organization + +### Location +- Path: `.cursor/rules/` +- Extension: `.mdc` + +### Naming Convention +PREFIX-name.mdc where PREFIX is: +- 0XX: Core standards +- 1XX: Tool configs +- 3XX: Testing standards +- 1XXX: Language rules +- 2XXX: Framework rules +- 8XX: Workflows +- 9XX: Templates +- _name.mdc: Private rules + +### Glob Pattern Examples +Common glob patterns for different rule types: +- Core standards: .cursor/rules/*.mdc +- Language rules: src/**/*.{js,ts} +- Testing standards: **/*.test.{js,ts} +- React components: src/components/**/*.tsx +- Documentation: docs/**/*.md +- Configuration files: *.config.{js,json} +- Build artifacts: dist/**/* +- Multiple extensions: src/**/*.{js,jsx,ts,tsx} +- Multiple files: dist/**/*, docs/**/*.md + +## Required Fields + +### Frontmatter +- description: ACTION TRIGGER OUTCOME format +- globs: `glob pattern for files and folders` + +### Body +- X.Y.Z +- context: Usage conditions +- requirements: Actionable items +- examples: Both valid and invalid + +## Formatting Guidelines + +- Use Concise Markdown primarily +- XML tags limited to: + - + - + - + - + - + - + - +- Always indent content within XML or nested XML tags by 2 spaces +- Keep rules as short as possbile +- Use Mermaid syntax if it will be shorter or clearer than describing a complex rule +- Use Emojis where appropriate to convey meaning that will improve rule understanding by the AI Agent +- Keep examples as short as possible to clearly convey the positive or negative example + +## AI Optimization Tips + +1. Use precise, deterministic ACTION TRIGGER OUTCOME format in descriptions +2. Provide concise positive and negative example of rule application in practice +3. Optimize for AI context window efficiency +4. Remove any non-essential or redundant information +5. Use standard glob patterns without quotes (e.g., *.js, src/**/*.ts) + +## AI Context Efficiency + +1. Keep frontmatter description under 120 characters (or less) while maintaining clear intent for rule selection by AI AGent +2. Limit examples to essential patterns only +3. Use hierarchical structure for quick parsing +4. Remove redundant information across sections +5. Maintain high information density with minimal tokens +6. Focus on machine-actionable instructions over human explanations + + + - NEVER include verbose explanations or redundant context that increases AI token overhead + - Keep file as short and to the point as possible BUT NEVER at the expense of sacrificing rule impact and usefulness for the AI Agent. + - the front matter can ONLY have the fields description and globs. + diff --git a/.cursor/rules/1000-rails-general.mdc b/.cursor/rules/1000-rails-general.mdc new file mode 100644 index 00000000..1fc74618 --- /dev/null +++ b/.cursor/rules/1000-rails-general.mdc @@ -0,0 +1,87 @@ +--- +description: Follow general Rails 8 conventions and patterns +globs: ["**/*.rb", "app/**/*.erb"] +--- + +# General Rails 8 Conventions + +## Context + +- In Ruby on Rails 8 application +- Using modern Rails features and patterns +- Follows Ruby style conventions + +## Requirements + +- Follow Ruby style guidelines (2 spaces for indentation, snake_case for variables/methods) +- Use Service Objects for complex business logic +- Use concerns for shared functionality +- Use modules for namespacing and code organization +- Use class Module::ClassName instead of nested module/class definitions +- Add YARD documentation to methods and classes +- Use positional arguments in enums and other Rails 8 features +- Use credentials with Rails.application.credentials syntax +- Pass models to jobs, not IDs (they'll be serialized automatically) +- Use has_prefix_id for models with UUIDs +- Use ActiveRecord conventions for database operations +- Follow RESTful conventions for controllers +- Use Tailwind CSS for styling views +- Make all UI components responsive and support dark mode +- Use Hotwire (Turbo, Stimulus) for JavaScript functionality +- Ensure accessibility in all UI components + +## Examples + + +```ruby +# Good - Using Rails 8 credentials +api_key = Rails.application.credentials.anthropic[:api_key] + +# Good - Using Service Object + +user = CreateUserService.new(user_params).run + +# Good - Using positional arguments in enum + +class Article < ApplicationRecord enum status: [:draft, :published, :archived] end + +# Good - Class namespacing + +class Api::V1::UsersController < ApplicationController + +# ... + +end + +```` + + + +```ruby +# Bad - Using outdated credentials pattern +api_key = Rails.application.secrets.anthropic_api_key + +# Bad - Complex logic in controller +def create + @user = User.new(user_params) + if @user.save + # Complex business logic here + end +end + +# Bad - Using hash for enum +class Article < ApplicationRecord + enum status: { draft: 0, published: 1, archived: 2 } +end + +# Bad - Nested modules +module Api + module V1 + class UsersController < ApplicationController + # ... + end + end +end +```` + + diff --git a/.cursor/rules/1001-rails-controllers.mdc b/.cursor/rules/1001-rails-controllers.mdc new file mode 100644 index 00000000..ba785ec5 --- /dev/null +++ b/.cursor/rules/1001-rails-controllers.mdc @@ -0,0 +1,82 @@ +--- +description: Follow Rails 8 controller standards and patterns when creating or editing controllers +globs: ["app/controllers/**/*.rb"] +--- + +# Rails Controller Standards + +## Context + +- In Ruby on Rails 8 controllers +- Controllers should follow RESTful conventions +- Comment style includes HTTP verb and path + +## Requirements + +- Add controller method comments with HTTP verb and full path +- Use resource-based naming +- Use before_action for common setup +- Namespace API controllers under Api::V1 +- Use respond_to for format handling (HTML/JSON) +- Use pagy for pagination: `@pagy, @resources = pagy(Resource.sort_by_params(params[:sort], sort_direction))` +- Use `status: :see_other` for redirects after DELETE +- Use `status: :unprocessable_content` for failed creates/updates +- Return 404 for records not found with `rescue ActiveRecord::RecordNotFound` +- Use `params.expect(:resource)` instead of `params.require` +- Include authentication callbacks where needed + +## Examples + + +```ruby +class CategoriesController < ApplicationController + before_action :set_category, only: [:show, :edit, :update, :destroy] + +# GET /categories + +def index @pagy, @categories = pagy(Category.sort_by_params(params[:sort], sort_direction)) end + +# POST /categories + +def create @category = Category.new(category_params) + + respond_to do |format| + if @category.save + format.html { redirect_to @category, notice: "Category was successfully created." } + format.json { render :show, status: :created, location: @category } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @category.errors, status: :unprocessable_content } + end + end + +end + +private + +def set_category @category = Category.find(params.expect(:id)) rescue ActiveRecord::RecordNotFound redirect_to categories_path end + +def category_params params.expect(category: [:name, :description]) end end + +```` + + + +```ruby +class CategoriesController < ApplicationController + def index + @categories = Category.all + end + + def create + @category = Category.new(params.permit(:name, :description)) + if @category.save + redirect_to @category + else + render :new + end + end +end +```` + + diff --git a/.cursor/rules/1002-rails-models.mdc b/.cursor/rules/1002-rails-models.mdc new file mode 100644 index 00000000..2f55ecc2 --- /dev/null +++ b/.cursor/rules/1002-rails-models.mdc @@ -0,0 +1,105 @@ +--- +description: Follow Rails 8 model standards and patterns when creating or modifying models +globs: ["app/models/**/*.rb"] +--- + +# Rails Model Standards + +## Context + +- In Ruby on Rails 8 models +- Use modern Rails patterns like store_accessor, concerns, and modules +- Add YARD documentation + +## Requirements + +- Add YARD documentation to all methods +- Use positional arguments in enums +- Include a separate module for specific roles, validations, or other functionality +- Define constants at the top of the file +- Use has_prefix_id for models with UUIDs +- Use strong typing with attribute declarations +- Use normalizes for attribute normalization +- Include Searchable concern for models that need search +- Use counter_cache for belongs_to relationships that need counts +- Use store_accessor for models with JSON columns +- Use acts_as_tenant for multi-tenant models +- Include validation for uploaded files with resizable_image +- Pass models to jobs, not IDs (they will be serialized) +- Use strong typing in models with `attribute` declarations + +## Examples + + +```ruby +# frozen_string_literal: true + +class Plan < ApplicationRecord has_prefix_id :plan + +INTERVALS = [:month, :year].freeze + +# Store JSON attributes in the details column + +# @return [Array] List of features for this plan + +store_accessor :details, :features, :stripe_tax + +# Define default attributes + +attribute :currency, default: "usd" + +# Normalize attributes before saving + +normalizes :currency, with: ->(currency) { currency.downcase } + +# Validations + +validates :name, :amount, :interval, presence: true validates :currency, presence: true, format: {with: /\A[a-zA-Z]{3}\z/, message: "must be a 3-letter ISO currency code"} validates :interval, inclusion: INTERVALS validates :trial_period_days, numericality: {only_integer: true} validates :unit_label, presence: {if: :charge_per_unit?} + +# Scopes + +scope :hidden, -> { where(hidden: true) } scope :visible, -> { where(hidden: [nil, false]) } scope :monthly, -> { where(interval: :month) } scope :yearly, -> { where(interval: :year) } scope :sorted, -> { order(amount: :asc) } + +# Returns a list of features for this plan + +# @return [Array] List of features + +def features Array.wrap(super) end + +# Checks if this plan has a trial period + +# @return [Boolean] True if the plan has a trial + +def has_trial? trial_period_days > 0 end + +# Checks if this plan is a monthly plan + +# @return [Boolean] True if the plan is monthly + +def monthly? interval == "month" end end + +```` + + + +```ruby +class Plan < ApplicationRecord + def self.free + where(name: "Free").first_or_initialize + end + + def features + super + end + + def has_trial? + self.trial_period_days > 0 + end + + def self.get_all + all + end +end +```` + + diff --git a/.cursor/rules/1003-rails-views.mdc b/.cursor/rules/1003-rails-views.mdc new file mode 100644 index 00000000..be8d5839 --- /dev/null +++ b/.cursor/rules/1003-rails-views.mdc @@ -0,0 +1,107 @@ +--- +description: Follow Rails 8.0 view standards with Tailwind CSS in ERB templates +globs: ["app/views/**/*.html.erb", "lib/jumpstart/app/views/**/*.html.erb"] +--- + +# Rails View Standards with Tailwind + +## Context + +- In Ruby on Rails 8.0 views +- Using Tailwind CSS for styling +- ERB templates with responsive design +- Support for dark mode + +## Requirements + +- Always use Tailwind CSS classes for styling +- Make all views responsive with breakpoints (sm:, md:, lg:, xl:, 2xl:) +- Add dark mode support (dark: prefix for Tailwind classes) +- For forms: + - Add autofocus: true on first input for new records + - Add asterisks (\*) for required fields + - Use HTML5 validation + - Use form-group, form-control classes + - For buttons use btn btn-primary, btn-small, btn-block, etc. + - Use f.button instead of f.submit + - Add proper disable_with for buttons showing loading state: disable_with: "Saving..." +- Use content_for :title for page titles +- For components like cards, alerts, navigation: + - Use corresponding Tailwind component classes +- Use Turbo Stream for real-time updates +- Use dom_id for HTML element IDs +- Use partials for reusable components +- Use proper spacing with Tailwind (mt-4, mb-4, py-2, etc.) +- Implement proper aria attributes for accessibility + +## Examples + + +```erb +<% content_for :title, "Edit Profile" %> + +
+
+

Edit Profile

+
+ +
+ <%= form_with(model: @user, local: true) do |f| %> +
+ <%= f.label :name, "Name *" %> + <%= f.text_field :name, class: "form-control", autofocus: true, required: true %> +
+ +
+ <%= f.label :email, "Email *" %> + <%= f.email_field :email, class: "form-control", required: true %> +
+ +
+ <%= f.label :avatar %> +
+ + <%= f.file_field :avatar, id: "avatar" %> +
+
+ +
+ <%= f.button button_text("Save Changes", disable_with: "Saving..."), class: "btn btn-primary" %> +
+ <% end %> + +
+
+``` +
+ + +```erb +

Edit Profile

+ +
+ <%= form_with(model: @user, local: true) do |f| %> +
+ <%= f.label :name %> + <%= f.text_field :name %> +
+ +
+ <%= f.label :email %> + <%= f.email_field :email %> +
+ +
+ <%= f.submit "Save Changes" %> +
+ +<% end %> + +
+``` +
diff --git a/.cursor/rules/1004-javascript-stimulus.mdc b/.cursor/rules/1004-javascript-stimulus.mdc new file mode 100644 index 00000000..0eb0ddaf --- /dev/null +++ b/.cursor/rules/1004-javascript-stimulus.mdc @@ -0,0 +1,90 @@ +--- +description: Follow JavaScript and Stimulus controller standards when creating or modifying JS files +globs: ["app/javascript/**/*.js"] +--- + +# JavaScript and Stimulus Controller Standards + +## Context + +- In Ruby on Rails 8.0 JavaScript +- Using Stimulus.js for interactive components +- Using ES6 features +- Using TailwindCSS Stimulus Components + +## Requirements + +- Use ES6 syntax (arrow functions, destructuring, etc.) +- Add comments at the top of Stimulus controllers explaining their purpose and usage examples +- Follow Stimulus controller naming conventions +- Use data-controller, data-action, and data-[controller]-target attributes +- Define static targets, values, and classes at the top of controller classes +- Initialize connections in the connect() lifecycle method +- Clean up resources in the disconnect() lifecycle method +- Use the tailwindcss-stimulus-components library for common components: + - Alert, Dropdown, Modal, Tabs, Popover, Toggle, Slideover +- Keep controller actions focused on a single responsibility +- Use event delegation where appropriate +- Document values properties with their types and defaults +- Avoid DOM manipulation where possible, prefer toggling classes + +## Examples + + +```javascript +// Example usage: +//
+ +import { Controller } from "@hotwired/stimulus" import { autoUpdate, autoPlacement, computePosition, offset, arrow } from "@floating-ui/dom" + +export default class extends Controller { // Define expected properties and their types static values = { content: String, placement: String, offset: { type: Number, default: 6 }, allowHtml: { type: Boolean, default: true } } + +// Initialize on connection connect() { this.createTooltipElements() this.cleanup = autoUpdate(this.element, this.tooltip, this.updatePosition.bind(this)) this.addEvents() } + +// Clean up resources on disconnect disconnect() { this.removeEvents() this.tooltip?.remove() this.cleanup?.() } + +// Creates DOM elements for the tooltip createTooltipElements() { this.tooltip = document.createElement("div") this.tooltip.className = "tooltip" this.tooltip.setAttribute("role", "tooltip") + + this.tooltipContent = document.createElement("div") + this.tooltipContent.className = "tooltip-content" + + this.tooltipArrow = document.createElement("div") + this.tooltipArrow.className = "tooltip-arrow" + + this.tooltip.appendChild(this.tooltipContent) + this.tooltip.appendChild(this.tooltipArrow) + document.body.appendChild(this.tooltip) + + this.updateContent() + +} + +// Updates tooltip content when content value changes contentValueChanged() { this.updateContent() } } + +```` +
+ + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + var self = this + var tooltip = document.createElement("div") + tooltip.className = "tooltip" + tooltip.innerHTML = this.data.get("content") + document.body.appendChild(tooltip) + + this.element.addEventListener("mouseenter", function() { + tooltip.style.display = "block" + }) + + this.element.addEventListener("mouseleave", function() { + tooltip.style.display = "none" + }) + } +} +```` + + diff --git a/.cursor/rules/1005-service-objects.mdc b/.cursor/rules/1005-service-objects.mdc new file mode 100644 index 00000000..d4318cd4 --- /dev/null +++ b/.cursor/rules/1005-service-objects.mdc @@ -0,0 +1,129 @@ +--- +description: Follow service object standards when creating or modifying service classes +globs: ["app/services/**/*.rb"] +--- + +# Service Object Standards + +## Context + +- In Ruby on Rails 8.0 service objects +- Used to encapsulate business logic +- Follows PORO (Plain Old Ruby Object) principles + +## Requirements + +- Create service objects in app/services directory +- Name services with verb + noun format ending with "Service" (e.g., CreateUserService) +- Use class Service::ClassName instead of nested module class +- Use initialize method to accept parameters +- Include YARD documentation for all methods +- Implement a run (or call, perform, execute) method that performs the service's main action +- Return a result object or the expected return value +- Keep service objects focused on a single responsibility +- Validate parameters in initialize or a separate validate method +- Handle errors gracefully, either through exceptions or a result object +- Make services testable with Minitest +- Only modify state through explicit interfaces, not by relying on side effects + +## Examples + + +```ruby +# frozen_string_literal: true + +# Service for creating a new user with account + +# + +# @example + +# service = CreateUserService.new(email: "test@example.com", name: "Test User") + +# user = service.run + +# + +class CreateUserService + +# Initialize the service with user attributes + +# + +# @param [Hash] attributes The user attributes + +# @option attributes [String] :email User's email + +# @option attributes [String] :name User's name + +# @option attributes [String] :password User's password + +def initialize(attributes) @attributes = attributes @account_name = attributes.delete(:account_name) || "My Account" end + +# Runs the service to create a user and account + +# + +# @return [User] The created user + +# @raise [ActiveRecord::RecordInvalid] If validation fails + +def run User.transaction do create_user create_account create_account_user end + + @user + +end + +private + +# Creates a new user with the provided attributes + +# + +# @return [User] The new user + +def create_user @user = User.create!(@attributes) end + +# Creates a new account for the user + +# + +# @return [Account] The new account + +def create_account @account = Account.create!(name: @account_name, owner: @user) end + +# Creates the account user relationship + +# + +# @return [AccountUser] The account user relationship + +def create_account_user AccountUser.create!( account: @account, user: @user, admin: true ) end end + +```` + + + +```ruby +module Services + module Users + class Create + def initialize(email, name, password) + @email = email + @name = name + @password = password + end + + def call + user = User.new(email: @email, name: @name, password: @password) + user.save + account = Account.create(name: "My Account") + AccountUser.create(user: user, account: account) + return user + end + end + end +end +```` + + diff --git a/.cursor/rules/1006-testing.mdc b/.cursor/rules/1006-testing.mdc new file mode 100644 index 00000000..16fe1b84 --- /dev/null +++ b/.cursor/rules/1006-testing.mdc @@ -0,0 +1,71 @@ +--- +description: Follow testing standards when creating or modifying tests +globs: ["test/**/*.rb"] +--- + +# Testing Standards + +## Context + +- In Ruby on Rails 8 test files +- Using Minitest for testing +- Tests should be thorough and maintainable + +## Requirements + +- Use Minitest for all tests +- Write tests for models, controllers, services, and other components +- Use fixtures for test data +- Organize tests into appropriate test classes +- Use descriptive test names with test\_ prefix (e.g., test_valid_user_can_login) +- Use assertions that best match what you're testing +- Keep tests focused on a single concern +- Use setup/teardown methods for common setup code +- Use test helpers for reusable test code +- Test both happy and sad paths +- Test edge cases and boundary conditions +- Avoid testing the framework itself +- Keep tests independent and idempotent +- Don't pipe test output into cat (`bin/rails test` not `bin/rails test | cat`) + +## Examples + + +```ruby +require "test_helper" + +class UserTest < ActiveSupport::TestCase setup do @user = users(:one) @account = accounts(:one) end + +test "valid user" do assert @user.valid? end + +test "invalid without email" do @user.email = nil refute @user.valid? assert_not_nil @user.errors[:email] end + +test "can be assigned to account" do account_user = AccountUser.new(user: @user, account: @account) assert account_user.valid? end + +test "can have admin role" do account_user = AccountUser.new(user: @user, account: @account, admin: true) assert account_user.admin? assert_includes account_user.active_roles, :admin end end + +```` + + + +```ruby +require "test_helper" + +class UserTest < ActiveSupport::TestCase + def setup + @user = User.new(name: "Test User", email: "test@example.com") + end + + def test_it_works + @user.save + assert_equal 1, User.count + User.all.each do |u| + assert u.valid? + end + @user.update(email: nil) + assert_equal false, @user.valid? + end +end +```` + + diff --git a/.cursor/rules/1007-tailwindcss.mdc b/.cursor/rules/1007-tailwindcss.mdc new file mode 100644 index 00000000..ebaeaf41 --- /dev/null +++ b/.cursor/rules/1007-tailwindcss.mdc @@ -0,0 +1,203 @@ +--- +name: tailwind_v4 +description: Guide for using Tailwind CSS v4 instead of v3.x +globs: ["**/*.{js,css,erb,rb}"] +tags: + - tailwind + - css +--- + +# Tailwind CSS v4 + +## Core Changes + +- **CSS-first configuration**: Configuration is now done in CSS instead of JavaScript + - Use `@theme` directive in CSS instead of `tailwind.config.js` + - Example: + ```css + @import "tailwindcss"; + + @theme { + --font-display: "Satoshi", "sans-serif"; + --breakpoint-3xl: 1920px; + --color-avocado-500: oklch(0.84 0.18 117.33); + --ease-fluid: cubic-bezier(0.3, 0, 0, 1); + } + ``` +- Legacy `tailwind.config.js` files can still be imported using the `@config` directive: + ```css + @import "tailwindcss"; + @config "../../tailwind.config.js"; + ``` +- **CSS import syntax**: Use `@import "tailwindcss"` instead of `@tailwind` directives + - Old: `@tailwind base; @tailwind components; @tailwind utilities;` + - New: `@import "tailwindcss";` + +- **Package changes**: + - PostCSS plugin is now `@tailwindcss/postcss` (not `tailwindcss`) + - CLI is now `@tailwindcss/cli` + - Vite plugin is `@tailwindcss/vite` + - No need for `postcss-import` or `autoprefixer` anymore + +- **Native CSS cascade layers**: Uses real CSS `@layer` instead of Tailwind's custom implementation + +## Theme Configuration + +- **CSS theme variables**: All design tokens are available as CSS variables + - Namespace format: `--category-name` (e.g., `--color-blue-500`, `--font-sans`) + - Access in CSS: `var(--color-blue-500)` + - Available namespaces: + - `--color-*` : Color utilities like `bg-red-500` and `text-sky-300` + - `--font-*` : Font family utilities like `font-sans` + - `--text-*` : Font size utilities like `text-xl` + - `--font-weight-*` : Font weight utilities like `font-bold` + - `--tracking-*` : Letter spacing utilities like `tracking-wide` + - `--leading-*` : Line height utilities like `leading-tight` + - `--breakpoint-*` : Responsive breakpoint variants like `sm:*` + - `--container-*` : Container query variants like `@sm:*` and size utilities like `max-w-md` + - `--spacing-*` : Spacing and sizing utilities like `px-4` and `max-h-16` + - `--radius-*` : Border radius utilities like `rounded-sm` + - `--shadow-*` : Box shadow utilities like `shadow-md` + - `--inset-shadow-*` : Inset box shadow utilities like `inset-shadow-xs` + - `--drop-shadow-*` : Drop shadow filter utilities like `drop-shadow-md` + - `--blur-*` : Blur filter utilities like `blur-md` + - `--perspective-*` : Perspective utilities like `perspective-near` + - `--ease-*` : Transition timing function utilities like `ease-out` + - `--animate-*` : Animation utilities like `animate-spin` + + +- **Simplified theme configuration**: Many utilities no longer need theme configuration + - Utilities like `grid-cols-12`, `z-40`, and `opacity-70` work without configuration + - Data attributes like `data-selected:opacity-100` don't need configuration + +- **Dynamic spacing scale**: Derived from a single spacing value + - Default: `--spacing: 0.25rem` + - Every multiple of the base value is available (e.g., `mt-21` works automatically) + +- **Overriding theme namespaces**: + - Override entire namespace: `--font-*: initial;` + - Override entire theme: `--*: initial;` + + +## New Features + +- **Container query support**: Built-in now, no plugin needed + - `@container` for container context + - `@sm:`, `@md:`, etc. for container-based breakpoints + - `@max-md:` for max-width container queries + - Combine with `@min-md:@max-xl:hidden` for ranges + +- **3D transforms**: + - `transform-3d` enables 3D transforms + - `rotate-x-*`, `rotate-y-*`, `rotate-z-*` for 3D rotation + - `scale-z-*` for z-axis scaling + - `translate-z-*` for z-axis translation + - `perspective-*` utilities (`perspective-near`, `perspective-distant`, etc.) + - `perspective-origin-*` utilities + - `backface-visible` and `backface-hidden` + +- **Gradient enhancements**: + - Linear gradient angles: `bg-linear-45` (renamed from `bg-gradient-*`) + - Gradient interpolation: `bg-linear-to-r/oklch`, `bg-linear-to-r/srgb` + - Conic and radial gradients: `bg-conic`, `bg-radial-[at_25%_25%]` + +- **Shadow enhancements**: + - `inset-shadow-*` and `inset-ring-*` utilities + - Can be composed with regular `shadow-*` and `ring-*` + +- **New CSS property utilities**: + - `field-sizing-content` for auto-resizing textareas + - `scheme-light`, `scheme-dark` for `color-scheme` property + - `font-stretch-*` utilities for variable fonts + +## New Variants + +- **Composable variants**: Chain variants together + - Example: `group-has-data-potato:opacity-100` + +- **New variants**: + - `starting` variant for `@starting-style` transitions + - `not-*` variant for `:not()` pseudo-class + - `inert` variant for `inert` attribute + - `nth-*` variants (`nth-3:`, `nth-last-5:`, `nth-of-type-4:`, `nth-last-of-type-6:`) + - `in-*` variant (like `group-*` but without adding `group` class) + - `open` variant now supports `:popover-open` + - `**` variant for targeting all descendants + +## Custom Extensions + +- **Custom utilities**: Use `@utility` directive + ```css + @utility tab-4 { + tab-size: 4; + } + ``` + +- **Custom variants**: Use `@variant` directive + ```css + @variant pointer-coarse (@media (pointer: coarse)); + @variant theme-midnight (&:where([data-theme="midnight"] *)); + ``` + +- **Plugins**: Use `@plugin` directive + ```css + @plugin "@tailwindcss/typography"; + ``` + +## Breaking Changes + +- **Removed deprecated utilities**: + - `bg-opacity-*` → Use `bg-black/50` instead + - `text-opacity-*` → Use `text-black/50` instead + - And others: `border-opacity-*`, `divide-opacity-*`, etc. + +- **Renamed utilities**: + - `shadow-sm` → `shadow-xs` (and `shadow` → `shadow-sm`) + - `drop-shadow-sm` → `drop-shadow-xs` (and `drop-shadow` → `drop-shadow-sm`) + - `blur-sm` → `blur-xs` (and `blur` → `blur-sm`) + - `rounded-sm` → `rounded-xs` (and `rounded` → `rounded-sm`) + - `outline-none` → `outline-hidden` (for the old behavior) + +- **Default style changes**: + - Default border color is now `currentColor` (was `gray-200`) + - Default `ring` width is now 1px (was 3px) + - Placeholder text now uses current color at 50% opacity (was `gray-400`) + - Hover styles only apply on devices that support hover (`@media (hover: hover)`) + +- **Syntax changes**: + - CSS variables in arbitrary values: `bg-(--brand-color)` instead of `bg-[--brand-color]` + - Stacked variants now apply left-to-right (not right-to-left) + - Use CSS variables instead of `theme()` function + +## Advanced Configuration + +- **Using a prefix**: + ```css + @import "tailwindcss" prefix(tw); + ``` + - Results in classes like `tw:flex`, `tw:bg-red-500`, `tw:hover:bg-red-600` + +- **Source detection**: + - Automatic by default (ignores `.gitignore` files and binary files) + - Add sources: `@source "../node_modules/@my-company/ui-lib";` + - Disable automatic detection: `@import "tailwindcss" source(none);` + +- **Legacy config files**: + ```css + @import "tailwindcss"; + @config "../../tailwind.config.js"; + ``` + +- **Dark mode configuration**: + ```css + @import "tailwindcss"; + @variant dark (&:where(.dark, .dark *)); + ``` + +- **Container customization**: Extend with `@utility` + ```css + @utility container { + margin-inline: auto; + padding-inline: 2rem; + } + ``` \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 14b9df61..d0ff6dac 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,7 +1,7 @@ require "rubygems" module ApplicationHelper - def component_files(component, gem_name = "ruby_ui") + def component_files(component, gem_name = "seth_ruby_ui") # Find the gem specification gem_spec = Gem::Specification.find_by_name(gem_name) return [] unless gem_spec diff --git a/app/lib/ruby_ui/file_manager.rb b/app/lib/ruby_ui/file_manager.rb index b1b3d58b..d8152e1e 100644 --- a/app/lib/ruby_ui/file_manager.rb +++ b/app/lib/ruby_ui/file_manager.rb @@ -28,7 +28,7 @@ def dependencies(component_name) end def gem_path - @gem_path ||= Gem::Specification.find_by_name("ruby_ui").gem_dir + @gem_path ||= Gem::Specification.find_by_name("seth_ruby_ui").gem_dir end DEPENDENCIES = YAML.load_file(File.join(gem_path, "lib/generators/ruby_ui/dependencies.yml")).freeze From 1ea9c495badae3700224f2f1b4c17ae4280405c8 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 01:55:54 -0400 Subject: [PATCH 17/33] Refactor RubyUI generators to ensure compatibility with Rails loading - Updated component_generator.rb, all_generator.rb, docs_generator.rb, and install_generator.rb to handle Rails loading more gracefully. - Wrapped generator definitions in conditional checks to prevent errors if Rails is not available. - Improved structure and readability of generator methods for better maintainability. --- .../ruby_ui/component/all_generator.rb | 32 +++-- lib/generators/ruby_ui/component_generator.rb | 135 ++++++++++-------- .../ruby_ui/install/docs_generator.rb | 46 +++--- .../ruby_ui/install/install_generator.rb | 121 ++++++++-------- 4 files changed, 182 insertions(+), 152 deletions(-) diff --git a/lib/generators/ruby_ui/component/all_generator.rb b/lib/generators/ruby_ui/component/all_generator.rb index d8d5c274..2854f625 100644 --- a/lib/generators/ruby_ui/component/all_generator.rb +++ b/lib/generators/ruby_ui/component/all_generator.rb @@ -1,19 +1,27 @@ -module RubyUI - module Generators - module Component - class AllGenerator < Rails::Generators::Base - namespace "ruby_ui:component:all" +begin + require "rails/generators/base" +rescue LoadError + # Rails not available, skip generator definition +end + +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + module Component + class AllGenerator < Rails::Generators::Base + namespace "ruby_ui:component:all" - source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) - class_option :force, type: :boolean, default: false + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) + class_option :force, type: :boolean, default: false - def generate_components - say "Generating all components..." + def generate_components + say "Generating all components..." - Dir.children(self.class.source_root).each do |folder_name| - next if folder_name.ends_with?(".rb") + Dir.children(self.class.source_root).each do |folder_name| + next if folder_name.ends_with?(".rb") - run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}" + run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}" + end end end end diff --git a/lib/generators/ruby_ui/component_generator.rb b/lib/generators/ruby_ui/component_generator.rb index 9b4fa928..5bb72684 100644 --- a/lib/generators/ruby_ui/component_generator.rb +++ b/lib/generators/ruby_ui/component_generator.rb @@ -1,96 +1,105 @@ require_relative "javascript_utils" -module RubyUI - module Generators - class ComponentGenerator < Rails::Generators::Base - include RubyUI::Generators::JavascriptUtils - namespace "ruby_ui:component" +begin + require "rails/generators/base" +rescue LoadError + # Rails not available, skip generator definition +end - source_root File.expand_path("../../../app/components/ruby_ui", __dir__) - argument :component_name, type: :string, required: true - class_option :force, type: :boolean, default: false +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + class ComponentGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils - def generate_component - if component_not_found? - say "Component not found: #{component_name}", :red - exit - end + namespace "ruby_ui:component" - say "Generating #{component_name} files..." - end + source_root File.expand_path("../../../app/components/ruby_ui", __dir__) + argument :component_name, type: :string, required: true + class_option :force, type: :boolean, default: false - def copy_related_component_files - say "Generating components" + def generate_component + if component_not_found? + say "Component not found: #{component_name}", :red + exit + end - components_file_paths.each do |file_path| - component_file_name = file_path.split("/").last - copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), - force: options["force"] + say "Generating #{component_name} files..." end - end - def copy_js_files - return if js_controller_file_paths.empty? + def copy_related_component_files + say "Generating components" - say "Generating Stimulus controllers" - - js_controller_file_paths.each do |file_path| - controller_file_name = file_path.split("/").last - copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), - force: options["force"] + components_file_paths.each do |file_path| + component_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), + force: options["force"] + end end - # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)` - return if using_importmap? + def copy_js_files + return if js_controller_file_paths.empty? - say "Updating Stimulus controllers manifest" - run "rake stimulus:manifest:update" - end + say "Generating Stimulus controllers" - def install_dependencies - return if dependencies.blank? + js_controller_file_paths.each do |file_path| + controller_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), + force: options["force"] + end - say "Installing dependencies" + # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)` + return if using_importmap? - install_components_dependencies(dependencies["components"]) - install_gems_dependencies(dependencies["gems"]) - install_js_packages(dependencies["js_packages"]) - end + say "Updating Stimulus controllers manifest" + run "rake stimulus:manifest:update" + end - private + def install_dependencies + return if dependencies.blank? - def component_not_found? = !Dir.exist?(component_folder_path) + say "Installing dependencies" - def component_folder_name = component_name.underscore + install_components_dependencies(dependencies["components"]) + install_gems_dependencies(dependencies["gems"]) + install_js_packages(dependencies["js_packages"]) + end + + private + + def component_not_found? = !Dir.exist?(component_folder_path) + + def component_folder_name = component_name.underscore - def component_folder_path = File.join(self.class.source_root, component_folder_name) + def component_folder_path = File.join(self.class.source_root, component_folder_name) - def components_file_paths = Dir.glob(File.join(component_folder_path, "*.rb")) + def components_file_paths = Dir.glob(File.join(component_folder_path, "*.rb")) - def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js")) + def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js")) - def install_components_dependencies(components) - components&.each do |component| - run "bin/rails generate ruby_ui:component #{component} --force #{options["force"]}" + def install_components_dependencies(components) + components&.each do |component| + run "bin/rails generate ruby_ui:component #{component} --force #{options["force"]}" + end end - end - def install_gems_dependencies(gems) - gems&.each do |ruby_gem| - run "bundle show #{ruby_gem} > /dev/null 2>&1 || bundle add #{ruby_gem}" + def install_gems_dependencies(gems) + gems&.each do |ruby_gem| + run "bundle show #{ruby_gem} > /dev/null 2>&1 || bundle add #{ruby_gem}" + end end - end - def install_js_packages(js_packages) - js_packages&.each do |js_package| - install_js_package(js_package) + def install_js_packages(js_packages) + js_packages&.each do |js_package| + install_js_package(js_package) + end end - end - def dependencies - @dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze + def dependencies + @dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze - @dependencies[component_folder_name] + @dependencies[component_folder_name] + end end end end diff --git a/lib/generators/ruby_ui/install/docs_generator.rb b/lib/generators/ruby_ui/install/docs_generator.rb index 27390ca8..5d5d7ac1 100644 --- a/lib/generators/ruby_ui/install/docs_generator.rb +++ b/lib/generators/ruby_ui/install/docs_generator.rb @@ -1,31 +1,37 @@ # frozen_string_literal: true -require "rails/generators" +begin + require "rails/generators/base" +rescue LoadError + # Rails not available, skip generator definition +end -module RubyUI - module Generators - module Install - class DocsGenerator < Rails::Generators::Base - namespace "ruby_ui:install:docs" - source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) - class_option :force, type: :boolean, default: false +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + module Install + class DocsGenerator < Rails::Generators::Base + namespace "ruby_ui:install:docs" + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) + class_option :force, type: :boolean, default: false - def copy_docs_files - say "Installing RubyUI documentation files..." + def copy_docs_files + say "Installing RubyUI documentation files..." - docs_file_paths.each do |source_path| - dest_filename = File.basename(source_path).sub("_docs", "") - copy_file source_path, Rails.root.join("app/views/docs", dest_filename), force: options["force"] - end + docs_file_paths.each do |source_path| + dest_filename = File.basename(source_path).sub("_docs", "") + copy_file source_path, Rails.root.join("app/views/docs", dest_filename), force: options["force"] + end - say "" - say "Documentation installed to app/views/docs/", :green - end + say "" + say "Documentation installed to app/views/docs/", :green + end - private + private - def docs_file_paths - Dir.glob(File.join(self.class.source_root, "*", "*_docs.rb")) + def docs_file_paths + Dir.glob(File.join(self.class.source_root, "*", "*_docs.rb")) + end end end end diff --git a/lib/generators/ruby_ui/install/install_generator.rb b/lib/generators/ruby_ui/install/install_generator.rb index 3a85e727..a8505fdb 100644 --- a/lib/generators/ruby_ui/install/install_generator.rb +++ b/lib/generators/ruby_ui/install/install_generator.rb @@ -1,82 +1,89 @@ -require "rails/generators" +begin + require "rails/generators/base" +rescue LoadError + # Rails not available, skip generator definition +end + require_relative "../javascript_utils" -module RubyUI - module Generators - class InstallGenerator < Rails::Generators::Base - include RubyUI::Generators::JavascriptUtils +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + class InstallGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils - namespace "ruby_ui:install" + namespace "ruby_ui:install" - source_root File.expand_path("templates", __dir__) + source_root File.expand_path("templates", __dir__) - def install_phlex_rails - say "Checking for phlex-rails" + def install_phlex_rails + say "Checking for phlex-rails" - if gem_installed?("phlex-rails") - say "phlex-rails is already installed", :green - else - say "Adding phlex-rails to Gemfile" - run %(bundle add phlex-rails) + if gem_installed?("phlex-rails") + say "phlex-rails is already installed", :green + else + say "Adding phlex-rails to Gemfile" + run %(bundle add phlex-rails) - say "Generating phlex-rails structure" - run "bin/rails generate phlex:install" + say "Generating phlex-rails structure" + run "bin/rails generate phlex:install" + end end - end - def install_tailwind_merge - say "Checking for tailwind_merge" + def install_tailwind_merge + say "Checking for tailwind_merge" - if gem_installed?("tailwind_merge") - say "tailwind_merge is already installed", :green - else - say "Adding phlex-rails to Gemfile" - run %(bundle add tailwind_merge) + if gem_installed?("tailwind_merge") + say "tailwind_merge is already installed", :green + else + say "Adding phlex-rails to Gemfile" + run %(bundle add tailwind_merge) + end end - end - def install_ruby_ui_initializer - say "Creating RubyUI initializer" - template "ruby_ui.rb.erb", Rails.root.join("config/initializers/ruby_ui.rb") - end + def install_ruby_ui_initializer + say "Creating RubyUI initializer" + template "ruby_ui.rb.erb", Rails.root.join("config/initializers/ruby_ui.rb") + end - def add_ruby_ui_module_to_components_base - say "Adding RubyUI Kit to Components::Base" - insert_into_file Rails.root.join("app/components/base.rb"), after: "class Components::Base < Phlex::HTML" do - "\n include RubyUI" + def add_ruby_ui_module_to_components_base + say "Adding RubyUI Kit to Components::Base" + insert_into_file Rails.root.join("app/components/base.rb"), after: "class Components::Base < Phlex::HTML" do + "\n include RubyUI" + end end - end - def add_tailwind_css - say "Adding Tailwind css" + def add_tailwind_css + say "Adding Tailwind css" - css_path = if using_importmap? - Rails.root.join("app/assets/tailwind/application.css") - else - Rails.root.join("app/assets/stylesheets/application.tailwind.css") - end + css_path = if using_importmap? + Rails.root.join("app/assets/tailwind/application.css") + else + Rails.root.join("app/assets/stylesheets/application.tailwind.css") + end - template "tailwind.css.erb", css_path - end + template "tailwind.css.erb", css_path + end - def install_tailwind_plugins - say "Installing tw-animate-css plugin" - install_js_package("tw-animate-css") - end + def install_tailwind_plugins + say "Installing tw-animate-css plugin" + install_js_package("tw-animate-css") + end - def add_ruby_ui_base - say "Adding RubyUI::Base component" - template "../../../../app/components/ruby_ui/base.rb", Rails.root.join("app/components/ruby_ui/base.rb") - end + def add_ruby_ui_base + say "Adding RubyUI::Base component" + template "../../../../app/components/ruby_ui/base.rb", Rails.root.join("app/components/ruby_ui/base.rb") + end - private + private - def gem_installed?(name) - Gem::Specification.find_all_by_name(name).any? - end + def gem_installed?(name) + Gem::Specification.find_all_by_name(name).any? + end - def using_tailwindcss_rails_gem? - File.exist?(Rails.root.join("app/assets/tailwind/application.css")) + def using_tailwindcss_rails_gem? + File.exist?(Rails.root.join("app/assets/tailwind/application.css")) + end end end end From 84ac7f1245df9390baf01edac567788e6cf23206 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 02:05:22 -0400 Subject: [PATCH 18/33] =?UTF-8?q?=F0=9F=98=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/000-cursor-rules.mdc | 113 ---------- .cursor/rules/1000-rails-general.mdc | 87 -------- .cursor/rules/1001-rails-controllers.mdc | 82 ------- .cursor/rules/1002-rails-models.mdc | 105 --------- .cursor/rules/1003-rails-views.mdc | 107 --------- .cursor/rules/1004-javascript-stimulus.mdc | 90 -------- .cursor/rules/1005-service-objects.mdc | 129 ----------- .cursor/rules/1006-testing.mdc | 71 ------ .cursor/rules/1007-tailwindcss.mdc | 203 ------------------ AGENT.md | 137 ------------ AGENTS.md | 76 +++++++ config/application.rb | 2 +- .../ruby_ui/component/all_generator.rb | 2 +- lib/generators/ruby_ui/component_generator.rb | 2 +- .../ruby_ui/install/docs_generator.rb | 2 +- .../ruby_ui/install/install_generator.rb | 2 +- 16 files changed, 81 insertions(+), 1129 deletions(-) delete mode 100644 .cursor/rules/000-cursor-rules.mdc delete mode 100644 .cursor/rules/1000-rails-general.mdc delete mode 100644 .cursor/rules/1001-rails-controllers.mdc delete mode 100644 .cursor/rules/1002-rails-models.mdc delete mode 100644 .cursor/rules/1003-rails-views.mdc delete mode 100644 .cursor/rules/1004-javascript-stimulus.mdc delete mode 100644 .cursor/rules/1005-service-objects.mdc delete mode 100644 .cursor/rules/1006-testing.mdc delete mode 100644 .cursor/rules/1007-tailwindcss.mdc delete mode 100644 AGENT.md create mode 100644 AGENTS.md diff --git a/.cursor/rules/000-cursor-rules.mdc b/.cursor/rules/000-cursor-rules.mdc deleted file mode 100644 index 084e8ea5..00000000 --- a/.cursor/rules/000-cursor-rules.mdc +++ /dev/null @@ -1,113 +0,0 @@ ---- -description: Use ALWAYS when asked to CREATE A RULE or UPDATE A RULE or taught a lesson from the user that should be retained as a new rule for Cursor -globs: [".cursor/rules/*.mdc"] ---- -# Cursor Rules Format -## Core Structure - -```mdc ---- -description: ACTION when TRIGGER to OUTCOME -globs: *.mdc ---- - -# Rule Title - -## Context -- When to apply this rule -- Prerequisites or conditions - -## Requirements -- Concise, actionable items -- Each requirement must be testable - -## Examples - -Good concise example with explanation - - - -Invalid concise example with explanation - -``` - -## File Organization - -### Location -- Path: `.cursor/rules/` -- Extension: `.mdc` - -### Naming Convention -PREFIX-name.mdc where PREFIX is: -- 0XX: Core standards -- 1XX: Tool configs -- 3XX: Testing standards -- 1XXX: Language rules -- 2XXX: Framework rules -- 8XX: Workflows -- 9XX: Templates -- _name.mdc: Private rules - -### Glob Pattern Examples -Common glob patterns for different rule types: -- Core standards: .cursor/rules/*.mdc -- Language rules: src/**/*.{js,ts} -- Testing standards: **/*.test.{js,ts} -- React components: src/components/**/*.tsx -- Documentation: docs/**/*.md -- Configuration files: *.config.{js,json} -- Build artifacts: dist/**/* -- Multiple extensions: src/**/*.{js,jsx,ts,tsx} -- Multiple files: dist/**/*, docs/**/*.md - -## Required Fields - -### Frontmatter -- description: ACTION TRIGGER OUTCOME format -- globs: `glob pattern for files and folders` - -### Body -- X.Y.Z -- context: Usage conditions -- requirements: Actionable items -- examples: Both valid and invalid - -## Formatting Guidelines - -- Use Concise Markdown primarily -- XML tags limited to: - - - - - - - - - - - - - - -- Always indent content within XML or nested XML tags by 2 spaces -- Keep rules as short as possbile -- Use Mermaid syntax if it will be shorter or clearer than describing a complex rule -- Use Emojis where appropriate to convey meaning that will improve rule understanding by the AI Agent -- Keep examples as short as possible to clearly convey the positive or negative example - -## AI Optimization Tips - -1. Use precise, deterministic ACTION TRIGGER OUTCOME format in descriptions -2. Provide concise positive and negative example of rule application in practice -3. Optimize for AI context window efficiency -4. Remove any non-essential or redundant information -5. Use standard glob patterns without quotes (e.g., *.js, src/**/*.ts) - -## AI Context Efficiency - -1. Keep frontmatter description under 120 characters (or less) while maintaining clear intent for rule selection by AI AGent -2. Limit examples to essential patterns only -3. Use hierarchical structure for quick parsing -4. Remove redundant information across sections -5. Maintain high information density with minimal tokens -6. Focus on machine-actionable instructions over human explanations - - - - NEVER include verbose explanations or redundant context that increases AI token overhead - - Keep file as short and to the point as possible BUT NEVER at the expense of sacrificing rule impact and usefulness for the AI Agent. - - the front matter can ONLY have the fields description and globs. - diff --git a/.cursor/rules/1000-rails-general.mdc b/.cursor/rules/1000-rails-general.mdc deleted file mode 100644 index 1fc74618..00000000 --- a/.cursor/rules/1000-rails-general.mdc +++ /dev/null @@ -1,87 +0,0 @@ ---- -description: Follow general Rails 8 conventions and patterns -globs: ["**/*.rb", "app/**/*.erb"] ---- - -# General Rails 8 Conventions - -## Context - -- In Ruby on Rails 8 application -- Using modern Rails features and patterns -- Follows Ruby style conventions - -## Requirements - -- Follow Ruby style guidelines (2 spaces for indentation, snake_case for variables/methods) -- Use Service Objects for complex business logic -- Use concerns for shared functionality -- Use modules for namespacing and code organization -- Use class Module::ClassName instead of nested module/class definitions -- Add YARD documentation to methods and classes -- Use positional arguments in enums and other Rails 8 features -- Use credentials with Rails.application.credentials syntax -- Pass models to jobs, not IDs (they'll be serialized automatically) -- Use has_prefix_id for models with UUIDs -- Use ActiveRecord conventions for database operations -- Follow RESTful conventions for controllers -- Use Tailwind CSS for styling views -- Make all UI components responsive and support dark mode -- Use Hotwire (Turbo, Stimulus) for JavaScript functionality -- Ensure accessibility in all UI components - -## Examples - - -```ruby -# Good - Using Rails 8 credentials -api_key = Rails.application.credentials.anthropic[:api_key] - -# Good - Using Service Object - -user = CreateUserService.new(user_params).run - -# Good - Using positional arguments in enum - -class Article < ApplicationRecord enum status: [:draft, :published, :archived] end - -# Good - Class namespacing - -class Api::V1::UsersController < ApplicationController - -# ... - -end - -```` - - - -```ruby -# Bad - Using outdated credentials pattern -api_key = Rails.application.secrets.anthropic_api_key - -# Bad - Complex logic in controller -def create - @user = User.new(user_params) - if @user.save - # Complex business logic here - end -end - -# Bad - Using hash for enum -class Article < ApplicationRecord - enum status: { draft: 0, published: 1, archived: 2 } -end - -# Bad - Nested modules -module Api - module V1 - class UsersController < ApplicationController - # ... - end - end -end -```` - - diff --git a/.cursor/rules/1001-rails-controllers.mdc b/.cursor/rules/1001-rails-controllers.mdc deleted file mode 100644 index ba785ec5..00000000 --- a/.cursor/rules/1001-rails-controllers.mdc +++ /dev/null @@ -1,82 +0,0 @@ ---- -description: Follow Rails 8 controller standards and patterns when creating or editing controllers -globs: ["app/controllers/**/*.rb"] ---- - -# Rails Controller Standards - -## Context - -- In Ruby on Rails 8 controllers -- Controllers should follow RESTful conventions -- Comment style includes HTTP verb and path - -## Requirements - -- Add controller method comments with HTTP verb and full path -- Use resource-based naming -- Use before_action for common setup -- Namespace API controllers under Api::V1 -- Use respond_to for format handling (HTML/JSON) -- Use pagy for pagination: `@pagy, @resources = pagy(Resource.sort_by_params(params[:sort], sort_direction))` -- Use `status: :see_other` for redirects after DELETE -- Use `status: :unprocessable_content` for failed creates/updates -- Return 404 for records not found with `rescue ActiveRecord::RecordNotFound` -- Use `params.expect(:resource)` instead of `params.require` -- Include authentication callbacks where needed - -## Examples - - -```ruby -class CategoriesController < ApplicationController - before_action :set_category, only: [:show, :edit, :update, :destroy] - -# GET /categories - -def index @pagy, @categories = pagy(Category.sort_by_params(params[:sort], sort_direction)) end - -# POST /categories - -def create @category = Category.new(category_params) - - respond_to do |format| - if @category.save - format.html { redirect_to @category, notice: "Category was successfully created." } - format.json { render :show, status: :created, location: @category } - else - format.html { render :new, status: :unprocessable_content } - format.json { render json: @category.errors, status: :unprocessable_content } - end - end - -end - -private - -def set_category @category = Category.find(params.expect(:id)) rescue ActiveRecord::RecordNotFound redirect_to categories_path end - -def category_params params.expect(category: [:name, :description]) end end - -```` - - - -```ruby -class CategoriesController < ApplicationController - def index - @categories = Category.all - end - - def create - @category = Category.new(params.permit(:name, :description)) - if @category.save - redirect_to @category - else - render :new - end - end -end -```` - - diff --git a/.cursor/rules/1002-rails-models.mdc b/.cursor/rules/1002-rails-models.mdc deleted file mode 100644 index 2f55ecc2..00000000 --- a/.cursor/rules/1002-rails-models.mdc +++ /dev/null @@ -1,105 +0,0 @@ ---- -description: Follow Rails 8 model standards and patterns when creating or modifying models -globs: ["app/models/**/*.rb"] ---- - -# Rails Model Standards - -## Context - -- In Ruby on Rails 8 models -- Use modern Rails patterns like store_accessor, concerns, and modules -- Add YARD documentation - -## Requirements - -- Add YARD documentation to all methods -- Use positional arguments in enums -- Include a separate module for specific roles, validations, or other functionality -- Define constants at the top of the file -- Use has_prefix_id for models with UUIDs -- Use strong typing with attribute declarations -- Use normalizes for attribute normalization -- Include Searchable concern for models that need search -- Use counter_cache for belongs_to relationships that need counts -- Use store_accessor for models with JSON columns -- Use acts_as_tenant for multi-tenant models -- Include validation for uploaded files with resizable_image -- Pass models to jobs, not IDs (they will be serialized) -- Use strong typing in models with `attribute` declarations - -## Examples - - -```ruby -# frozen_string_literal: true - -class Plan < ApplicationRecord has_prefix_id :plan - -INTERVALS = [:month, :year].freeze - -# Store JSON attributes in the details column - -# @return [Array] List of features for this plan - -store_accessor :details, :features, :stripe_tax - -# Define default attributes - -attribute :currency, default: "usd" - -# Normalize attributes before saving - -normalizes :currency, with: ->(currency) { currency.downcase } - -# Validations - -validates :name, :amount, :interval, presence: true validates :currency, presence: true, format: {with: /\A[a-zA-Z]{3}\z/, message: "must be a 3-letter ISO currency code"} validates :interval, inclusion: INTERVALS validates :trial_period_days, numericality: {only_integer: true} validates :unit_label, presence: {if: :charge_per_unit?} - -# Scopes - -scope :hidden, -> { where(hidden: true) } scope :visible, -> { where(hidden: [nil, false]) } scope :monthly, -> { where(interval: :month) } scope :yearly, -> { where(interval: :year) } scope :sorted, -> { order(amount: :asc) } - -# Returns a list of features for this plan - -# @return [Array] List of features - -def features Array.wrap(super) end - -# Checks if this plan has a trial period - -# @return [Boolean] True if the plan has a trial - -def has_trial? trial_period_days > 0 end - -# Checks if this plan is a monthly plan - -# @return [Boolean] True if the plan is monthly - -def monthly? interval == "month" end end - -```` - - - -```ruby -class Plan < ApplicationRecord - def self.free - where(name: "Free").first_or_initialize - end - - def features - super - end - - def has_trial? - self.trial_period_days > 0 - end - - def self.get_all - all - end -end -```` - - diff --git a/.cursor/rules/1003-rails-views.mdc b/.cursor/rules/1003-rails-views.mdc deleted file mode 100644 index be8d5839..00000000 --- a/.cursor/rules/1003-rails-views.mdc +++ /dev/null @@ -1,107 +0,0 @@ ---- -description: Follow Rails 8.0 view standards with Tailwind CSS in ERB templates -globs: ["app/views/**/*.html.erb", "lib/jumpstart/app/views/**/*.html.erb"] ---- - -# Rails View Standards with Tailwind - -## Context - -- In Ruby on Rails 8.0 views -- Using Tailwind CSS for styling -- ERB templates with responsive design -- Support for dark mode - -## Requirements - -- Always use Tailwind CSS classes for styling -- Make all views responsive with breakpoints (sm:, md:, lg:, xl:, 2xl:) -- Add dark mode support (dark: prefix for Tailwind classes) -- For forms: - - Add autofocus: true on first input for new records - - Add asterisks (\*) for required fields - - Use HTML5 validation - - Use form-group, form-control classes - - For buttons use btn btn-primary, btn-small, btn-block, etc. - - Use f.button instead of f.submit - - Add proper disable_with for buttons showing loading state: disable_with: "Saving..." -- Use content_for :title for page titles -- For components like cards, alerts, navigation: - - Use corresponding Tailwind component classes -- Use Turbo Stream for real-time updates -- Use dom_id for HTML element IDs -- Use partials for reusable components -- Use proper spacing with Tailwind (mt-4, mb-4, py-2, etc.) -- Implement proper aria attributes for accessibility - -## Examples - - -```erb -<% content_for :title, "Edit Profile" %> - -
-
-

Edit Profile

-
- -
- <%= form_with(model: @user, local: true) do |f| %> -
- <%= f.label :name, "Name *" %> - <%= f.text_field :name, class: "form-control", autofocus: true, required: true %> -
- -
- <%= f.label :email, "Email *" %> - <%= f.email_field :email, class: "form-control", required: true %> -
- -
- <%= f.label :avatar %> -
- - <%= f.file_field :avatar, id: "avatar" %> -
-
- -
- <%= f.button button_text("Save Changes", disable_with: "Saving..."), class: "btn btn-primary" %> -
- <% end %> - -
-
-``` -
- - -```erb -

Edit Profile

- -
- <%= form_with(model: @user, local: true) do |f| %> -
- <%= f.label :name %> - <%= f.text_field :name %> -
- -
- <%= f.label :email %> - <%= f.email_field :email %> -
- -
- <%= f.submit "Save Changes" %> -
- -<% end %> - -
-``` -
diff --git a/.cursor/rules/1004-javascript-stimulus.mdc b/.cursor/rules/1004-javascript-stimulus.mdc deleted file mode 100644 index 0eb0ddaf..00000000 --- a/.cursor/rules/1004-javascript-stimulus.mdc +++ /dev/null @@ -1,90 +0,0 @@ ---- -description: Follow JavaScript and Stimulus controller standards when creating or modifying JS files -globs: ["app/javascript/**/*.js"] ---- - -# JavaScript and Stimulus Controller Standards - -## Context - -- In Ruby on Rails 8.0 JavaScript -- Using Stimulus.js for interactive components -- Using ES6 features -- Using TailwindCSS Stimulus Components - -## Requirements - -- Use ES6 syntax (arrow functions, destructuring, etc.) -- Add comments at the top of Stimulus controllers explaining their purpose and usage examples -- Follow Stimulus controller naming conventions -- Use data-controller, data-action, and data-[controller]-target attributes -- Define static targets, values, and classes at the top of controller classes -- Initialize connections in the connect() lifecycle method -- Clean up resources in the disconnect() lifecycle method -- Use the tailwindcss-stimulus-components library for common components: - - Alert, Dropdown, Modal, Tabs, Popover, Toggle, Slideover -- Keep controller actions focused on a single responsibility -- Use event delegation where appropriate -- Document values properties with their types and defaults -- Avoid DOM manipulation where possible, prefer toggling classes - -## Examples - - -```javascript -// Example usage: -//
- -import { Controller } from "@hotwired/stimulus" import { autoUpdate, autoPlacement, computePosition, offset, arrow } from "@floating-ui/dom" - -export default class extends Controller { // Define expected properties and their types static values = { content: String, placement: String, offset: { type: Number, default: 6 }, allowHtml: { type: Boolean, default: true } } - -// Initialize on connection connect() { this.createTooltipElements() this.cleanup = autoUpdate(this.element, this.tooltip, this.updatePosition.bind(this)) this.addEvents() } - -// Clean up resources on disconnect disconnect() { this.removeEvents() this.tooltip?.remove() this.cleanup?.() } - -// Creates DOM elements for the tooltip createTooltipElements() { this.tooltip = document.createElement("div") this.tooltip.className = "tooltip" this.tooltip.setAttribute("role", "tooltip") - - this.tooltipContent = document.createElement("div") - this.tooltipContent.className = "tooltip-content" - - this.tooltipArrow = document.createElement("div") - this.tooltipArrow.className = "tooltip-arrow" - - this.tooltip.appendChild(this.tooltipContent) - this.tooltip.appendChild(this.tooltipArrow) - document.body.appendChild(this.tooltip) - - this.updateContent() - -} - -// Updates tooltip content when content value changes contentValueChanged() { this.updateContent() } } - -```` -
- - -```javascript -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - var self = this - var tooltip = document.createElement("div") - tooltip.className = "tooltip" - tooltip.innerHTML = this.data.get("content") - document.body.appendChild(tooltip) - - this.element.addEventListener("mouseenter", function() { - tooltip.style.display = "block" - }) - - this.element.addEventListener("mouseleave", function() { - tooltip.style.display = "none" - }) - } -} -```` - - diff --git a/.cursor/rules/1005-service-objects.mdc b/.cursor/rules/1005-service-objects.mdc deleted file mode 100644 index d4318cd4..00000000 --- a/.cursor/rules/1005-service-objects.mdc +++ /dev/null @@ -1,129 +0,0 @@ ---- -description: Follow service object standards when creating or modifying service classes -globs: ["app/services/**/*.rb"] ---- - -# Service Object Standards - -## Context - -- In Ruby on Rails 8.0 service objects -- Used to encapsulate business logic -- Follows PORO (Plain Old Ruby Object) principles - -## Requirements - -- Create service objects in app/services directory -- Name services with verb + noun format ending with "Service" (e.g., CreateUserService) -- Use class Service::ClassName instead of nested module class -- Use initialize method to accept parameters -- Include YARD documentation for all methods -- Implement a run (or call, perform, execute) method that performs the service's main action -- Return a result object or the expected return value -- Keep service objects focused on a single responsibility -- Validate parameters in initialize or a separate validate method -- Handle errors gracefully, either through exceptions or a result object -- Make services testable with Minitest -- Only modify state through explicit interfaces, not by relying on side effects - -## Examples - - -```ruby -# frozen_string_literal: true - -# Service for creating a new user with account - -# - -# @example - -# service = CreateUserService.new(email: "test@example.com", name: "Test User") - -# user = service.run - -# - -class CreateUserService - -# Initialize the service with user attributes - -# - -# @param [Hash] attributes The user attributes - -# @option attributes [String] :email User's email - -# @option attributes [String] :name User's name - -# @option attributes [String] :password User's password - -def initialize(attributes) @attributes = attributes @account_name = attributes.delete(:account_name) || "My Account" end - -# Runs the service to create a user and account - -# - -# @return [User] The created user - -# @raise [ActiveRecord::RecordInvalid] If validation fails - -def run User.transaction do create_user create_account create_account_user end - - @user - -end - -private - -# Creates a new user with the provided attributes - -# - -# @return [User] The new user - -def create_user @user = User.create!(@attributes) end - -# Creates a new account for the user - -# - -# @return [Account] The new account - -def create_account @account = Account.create!(name: @account_name, owner: @user) end - -# Creates the account user relationship - -# - -# @return [AccountUser] The account user relationship - -def create_account_user AccountUser.create!( account: @account, user: @user, admin: true ) end end - -```` - - - -```ruby -module Services - module Users - class Create - def initialize(email, name, password) - @email = email - @name = name - @password = password - end - - def call - user = User.new(email: @email, name: @name, password: @password) - user.save - account = Account.create(name: "My Account") - AccountUser.create(user: user, account: account) - return user - end - end - end -end -```` - - diff --git a/.cursor/rules/1006-testing.mdc b/.cursor/rules/1006-testing.mdc deleted file mode 100644 index 16fe1b84..00000000 --- a/.cursor/rules/1006-testing.mdc +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Follow testing standards when creating or modifying tests -globs: ["test/**/*.rb"] ---- - -# Testing Standards - -## Context - -- In Ruby on Rails 8 test files -- Using Minitest for testing -- Tests should be thorough and maintainable - -## Requirements - -- Use Minitest for all tests -- Write tests for models, controllers, services, and other components -- Use fixtures for test data -- Organize tests into appropriate test classes -- Use descriptive test names with test\_ prefix (e.g., test_valid_user_can_login) -- Use assertions that best match what you're testing -- Keep tests focused on a single concern -- Use setup/teardown methods for common setup code -- Use test helpers for reusable test code -- Test both happy and sad paths -- Test edge cases and boundary conditions -- Avoid testing the framework itself -- Keep tests independent and idempotent -- Don't pipe test output into cat (`bin/rails test` not `bin/rails test | cat`) - -## Examples - - -```ruby -require "test_helper" - -class UserTest < ActiveSupport::TestCase setup do @user = users(:one) @account = accounts(:one) end - -test "valid user" do assert @user.valid? end - -test "invalid without email" do @user.email = nil refute @user.valid? assert_not_nil @user.errors[:email] end - -test "can be assigned to account" do account_user = AccountUser.new(user: @user, account: @account) assert account_user.valid? end - -test "can have admin role" do account_user = AccountUser.new(user: @user, account: @account, admin: true) assert account_user.admin? assert_includes account_user.active_roles, :admin end end - -```` - - - -```ruby -require "test_helper" - -class UserTest < ActiveSupport::TestCase - def setup - @user = User.new(name: "Test User", email: "test@example.com") - end - - def test_it_works - @user.save - assert_equal 1, User.count - User.all.each do |u| - assert u.valid? - end - @user.update(email: nil) - assert_equal false, @user.valid? - end -end -```` - - diff --git a/.cursor/rules/1007-tailwindcss.mdc b/.cursor/rules/1007-tailwindcss.mdc deleted file mode 100644 index ebaeaf41..00000000 --- a/.cursor/rules/1007-tailwindcss.mdc +++ /dev/null @@ -1,203 +0,0 @@ ---- -name: tailwind_v4 -description: Guide for using Tailwind CSS v4 instead of v3.x -globs: ["**/*.{js,css,erb,rb}"] -tags: - - tailwind - - css ---- - -# Tailwind CSS v4 - -## Core Changes - -- **CSS-first configuration**: Configuration is now done in CSS instead of JavaScript - - Use `@theme` directive in CSS instead of `tailwind.config.js` - - Example: - ```css - @import "tailwindcss"; - - @theme { - --font-display: "Satoshi", "sans-serif"; - --breakpoint-3xl: 1920px; - --color-avocado-500: oklch(0.84 0.18 117.33); - --ease-fluid: cubic-bezier(0.3, 0, 0, 1); - } - ``` -- Legacy `tailwind.config.js` files can still be imported using the `@config` directive: - ```css - @import "tailwindcss"; - @config "../../tailwind.config.js"; - ``` -- **CSS import syntax**: Use `@import "tailwindcss"` instead of `@tailwind` directives - - Old: `@tailwind base; @tailwind components; @tailwind utilities;` - - New: `@import "tailwindcss";` - -- **Package changes**: - - PostCSS plugin is now `@tailwindcss/postcss` (not `tailwindcss`) - - CLI is now `@tailwindcss/cli` - - Vite plugin is `@tailwindcss/vite` - - No need for `postcss-import` or `autoprefixer` anymore - -- **Native CSS cascade layers**: Uses real CSS `@layer` instead of Tailwind's custom implementation - -## Theme Configuration - -- **CSS theme variables**: All design tokens are available as CSS variables - - Namespace format: `--category-name` (e.g., `--color-blue-500`, `--font-sans`) - - Access in CSS: `var(--color-blue-500)` - - Available namespaces: - - `--color-*` : Color utilities like `bg-red-500` and `text-sky-300` - - `--font-*` : Font family utilities like `font-sans` - - `--text-*` : Font size utilities like `text-xl` - - `--font-weight-*` : Font weight utilities like `font-bold` - - `--tracking-*` : Letter spacing utilities like `tracking-wide` - - `--leading-*` : Line height utilities like `leading-tight` - - `--breakpoint-*` : Responsive breakpoint variants like `sm:*` - - `--container-*` : Container query variants like `@sm:*` and size utilities like `max-w-md` - - `--spacing-*` : Spacing and sizing utilities like `px-4` and `max-h-16` - - `--radius-*` : Border radius utilities like `rounded-sm` - - `--shadow-*` : Box shadow utilities like `shadow-md` - - `--inset-shadow-*` : Inset box shadow utilities like `inset-shadow-xs` - - `--drop-shadow-*` : Drop shadow filter utilities like `drop-shadow-md` - - `--blur-*` : Blur filter utilities like `blur-md` - - `--perspective-*` : Perspective utilities like `perspective-near` - - `--ease-*` : Transition timing function utilities like `ease-out` - - `--animate-*` : Animation utilities like `animate-spin` - - -- **Simplified theme configuration**: Many utilities no longer need theme configuration - - Utilities like `grid-cols-12`, `z-40`, and `opacity-70` work without configuration - - Data attributes like `data-selected:opacity-100` don't need configuration - -- **Dynamic spacing scale**: Derived from a single spacing value - - Default: `--spacing: 0.25rem` - - Every multiple of the base value is available (e.g., `mt-21` works automatically) - -- **Overriding theme namespaces**: - - Override entire namespace: `--font-*: initial;` - - Override entire theme: `--*: initial;` - - -## New Features - -- **Container query support**: Built-in now, no plugin needed - - `@container` for container context - - `@sm:`, `@md:`, etc. for container-based breakpoints - - `@max-md:` for max-width container queries - - Combine with `@min-md:@max-xl:hidden` for ranges - -- **3D transforms**: - - `transform-3d` enables 3D transforms - - `rotate-x-*`, `rotate-y-*`, `rotate-z-*` for 3D rotation - - `scale-z-*` for z-axis scaling - - `translate-z-*` for z-axis translation - - `perspective-*` utilities (`perspective-near`, `perspective-distant`, etc.) - - `perspective-origin-*` utilities - - `backface-visible` and `backface-hidden` - -- **Gradient enhancements**: - - Linear gradient angles: `bg-linear-45` (renamed from `bg-gradient-*`) - - Gradient interpolation: `bg-linear-to-r/oklch`, `bg-linear-to-r/srgb` - - Conic and radial gradients: `bg-conic`, `bg-radial-[at_25%_25%]` - -- **Shadow enhancements**: - - `inset-shadow-*` and `inset-ring-*` utilities - - Can be composed with regular `shadow-*` and `ring-*` - -- **New CSS property utilities**: - - `field-sizing-content` for auto-resizing textareas - - `scheme-light`, `scheme-dark` for `color-scheme` property - - `font-stretch-*` utilities for variable fonts - -## New Variants - -- **Composable variants**: Chain variants together - - Example: `group-has-data-potato:opacity-100` - -- **New variants**: - - `starting` variant for `@starting-style` transitions - - `not-*` variant for `:not()` pseudo-class - - `inert` variant for `inert` attribute - - `nth-*` variants (`nth-3:`, `nth-last-5:`, `nth-of-type-4:`, `nth-last-of-type-6:`) - - `in-*` variant (like `group-*` but without adding `group` class) - - `open` variant now supports `:popover-open` - - `**` variant for targeting all descendants - -## Custom Extensions - -- **Custom utilities**: Use `@utility` directive - ```css - @utility tab-4 { - tab-size: 4; - } - ``` - -- **Custom variants**: Use `@variant` directive - ```css - @variant pointer-coarse (@media (pointer: coarse)); - @variant theme-midnight (&:where([data-theme="midnight"] *)); - ``` - -- **Plugins**: Use `@plugin` directive - ```css - @plugin "@tailwindcss/typography"; - ``` - -## Breaking Changes - -- **Removed deprecated utilities**: - - `bg-opacity-*` → Use `bg-black/50` instead - - `text-opacity-*` → Use `text-black/50` instead - - And others: `border-opacity-*`, `divide-opacity-*`, etc. - -- **Renamed utilities**: - - `shadow-sm` → `shadow-xs` (and `shadow` → `shadow-sm`) - - `drop-shadow-sm` → `drop-shadow-xs` (and `drop-shadow` → `drop-shadow-sm`) - - `blur-sm` → `blur-xs` (and `blur` → `blur-sm`) - - `rounded-sm` → `rounded-xs` (and `rounded` → `rounded-sm`) - - `outline-none` → `outline-hidden` (for the old behavior) - -- **Default style changes**: - - Default border color is now `currentColor` (was `gray-200`) - - Default `ring` width is now 1px (was 3px) - - Placeholder text now uses current color at 50% opacity (was `gray-400`) - - Hover styles only apply on devices that support hover (`@media (hover: hover)`) - -- **Syntax changes**: - - CSS variables in arbitrary values: `bg-(--brand-color)` instead of `bg-[--brand-color]` - - Stacked variants now apply left-to-right (not right-to-left) - - Use CSS variables instead of `theme()` function - -## Advanced Configuration - -- **Using a prefix**: - ```css - @import "tailwindcss" prefix(tw); - ``` - - Results in classes like `tw:flex`, `tw:bg-red-500`, `tw:hover:bg-red-600` - -- **Source detection**: - - Automatic by default (ignores `.gitignore` files and binary files) - - Add sources: `@source "../node_modules/@my-company/ui-lib";` - - Disable automatic detection: `@import "tailwindcss" source(none);` - -- **Legacy config files**: - ```css - @import "tailwindcss"; - @config "../../tailwind.config.js"; - ``` - -- **Dark mode configuration**: - ```css - @import "tailwindcss"; - @variant dark (&:where(.dark, .dark *)); - ``` - -- **Container customization**: Extend with `@utility` - ```css - @utility container { - margin-inline: auto; - padding-inline: 2rem; - } - ``` \ No newline at end of file diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 376bb108..00000000 --- a/AGENT.md +++ /dev/null @@ -1,137 +0,0 @@ -# AGENTS.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Overview - -Jumpstart Pro Rails is a commercial multi-tenant SaaS starter application built with Rails 8. It provides subscription billing, team management, authentication, and modern Rails patterns for building subscription-based web applications. - -## Development Commands - -```bash -# Initial setup -bin/setup # Install dependencies and setup database - -# Development server -bin/dev # Start development server with Overmind (includes Rails server, asset watching) -bin/rails server # Standard Rails server only - -# Database -bin/rails db:prepare # Setup database (creates, migrates, seeds) -bin/rails db:migrate # Run migrations -bin/rails db:seed # Seed database - -# Testing -bin/rails test # Run test suite (Minitest) -bin/rails test:system # Run system tests (Capybara + Selenium) - -# Code quality -bin/rubocop # Run RuboCop linter (configured in .rubocop.yml) -bin/rubocop -a # Auto-fix RuboCop issues - -# Background jobs -bin/jobs # Start SolidQueue worker (if using SolidQueue) -bundle exec sidekiq # Start Sidekiq worker (if using Sidekiq) -``` - -## Architecture - -### Multi-tenancy System -- **Account-based tenancy**: Users belong to Accounts (personal or team) -- **AccountUser model**: Join table managing user-account relationships with roles -- **Current account switching**: Users can switch between accounts via `switch_account(account)` -- **Authorization**: Pundit policies scope data by current account - -### Modular Models -Models use Ruby modules for organization: -```ruby -# app/models/user.rb -class User < ApplicationRecord - include Accounts, Agreements, Authenticatable, Mentions, Notifiable, Searchable, Theme -end - -# app/models/account.rb -class Account < ApplicationRecord - include Billing, Domains, Transfer, Types -end -``` - -### Jumpstart Configuration System -- **Dynamic configuration**: `config/jumpstart.yml` controls enabled features -- **Runtime gem loading**: `Gemfile.jumpstart` loads gems based on configuration -- **Feature toggles**: Payment processors, integrations, background jobs, etc. -- Access via `Jumpstart.config.payment_processors`, `Jumpstart.config.stripe?`, etc. - -### Payment Architecture -- **Pay gem (~11.0)**: Unified interface for multiple payment processors -- **Processor-agnostic**: Stripe, Paddle, Braintree, PayPal, Lemon Squeezy support -- **Per-seat billing**: Team accounts with usage-based pricing -- **Subscription management**: In `app/models/account/billing.rb` -- **Email delivery**: Mailgun, Mailpace, Postmark, and Resend use API gems instead of SMTP -- **API client errors**: Raise `UnprocessableContent` for 422 responses (rfc9110) - -## Technology Stack - -- **Rails 8** with Hotwire (Turbo + Stimulus) and Hotwire Native -- **PostgreSQL** (primary), **SolidQueue** (jobs), **SolidCache** (cache), **SolidCable** (websockets) -- **Import Maps** for JavaScript (no Node.js dependency) -- **TailwindCSS v4** via tailwindcss-rails gem -- **Devise** for authentication with custom extensions -- **Pundit** for authorization -- **Minitest** for testing with parallel execution - -## Testing - -- **Minitest** with fixtures in `test/fixtures/` -- **System tests** use Capybara with Selenium WebDriver -- **Test parallelization** enabled via `parallelize(workers: :number_of_processors)` -- **WebMock** configured to disable external HTTP requests -- **Test database** reset between runs - -## Routes Organization - -Routes are modularized in `config/routes/`: -- `accounts.rb` - Account management, switching, invitations -- `billing.rb` - Subscription, payment, receipt routes -- `users.rb` - User profile, settings, authentication -- `api.rb` - API v1 endpoints with JWT authentication - -## Key Directories - -- `app/controllers/accounts/` - Account-scoped controllers -- `app/models/concerns/` - Shared model modules -- `app/policies/` - Pundit authorization policies -- `lib/jumpstart/` - Core Jumpstart engine and configuration -- `config/routes/` - Modular route definitions -- `app/components/` - View components for reusable UI - -## Development Notes - -- **Current account** available via `current_account` helper in controllers/views -- **Account switching** via `switch_account(account)` in tests -- **Billing features** conditionally loaded based on `Jumpstart.config.payments_enabled?` -- **Background jobs** configurable between SolidQueue and Sidekiq -- **Multi-database** setup with separate databases for cache, jobs, and cable - -## Important Conventions - -### Do NOT -- Commit, push, or create PRs unless explicitly directed -- Use hash syntax for enums (use positional arguments) -- Skip tenant scoping for multi-tenant models - -### Git Operations - ALWAYS Confirm First -Before executing any permanent git or GitHub operation, **always ask for user confirmation**. This includes: -- `git commit` - Show the commit message and files to be committed -- `git push` - Show what will be pushed and to which remote/branch -- `gh pr create` - Show the PR title, description, and target branch -- `git reset --hard`, `git rebase`, or any destructive operations - -**Never run these commands without explicit user approval**, even if the user asked to "commit and push" or "create a PR". Always show what will happen first and wait for confirmation. - -### Do -- Use fixtures for test data -- Add YARD documentation to public methods -- Make UI responsive and dark-mode compatible -- Use Hotwire (Turbo + Stimulus) for JavaScript functionality -- Use the ruby ui components and app components instead of creating new ones. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..42ed5b37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Seth Ruby UI is a UI Component Library built on Phlex. It provides a set of reusable components for building web applications. + +## Development Commands + +```bash +# Initial setup +bin/setup # Install dependencies and setup database + +# Development server +bin/dev # Start development server with Overmind (includes Rails server, asset watching) +bin/rails server # Standard Rails server only + +# Database +bin/rails db:prepare # Setup database (creates, migrates, seeds) +bin/rails db:migrate # Run migrations +bin/rails db:seed # Seed database + +# Testing +bin/rails test # Run test suite (Minitest) +bin/rails test:system # Run system tests (Capybara + Selenium) + +# Code quality +bin/rubocop # Run RuboCop linter (configured in .rubocop.yml) +bin/rubocop -a # Auto-fix RuboCop issues + +# Background jobs +bin/jobs # Start SolidQueue worker (if using SolidQueue) +bundle exec sidekiq # Start Sidekiq worker (if using Sidekiq) +``` + +## Architecture + +## Technology Stack + +- **Rails 8** with Hotwire (Turbo + Stimulus) and Hotwire Native +- **Import Maps** for JavaScript (no Node.js dependency) +- **TailwindCSS v4** via tailwindcss-rails gem +- **Devise** for authentication with custom extensions +- **Minitest** for testing with parallel execution + +## Testing + +- **Minitest** with fixtures in `test/fixtures/` +- **System tests** use Capybara with Selenium WebDriver +- **Test parallelization** enabled via `parallelize(workers: :number_of_processors)` +- **WebMock** configured to disable external HTTP requests +- **Test database** reset between runs + +## Important Conventions + +### Do NOT +- Commit, push, or create PRs unless explicitly directed +- Use hash syntax for enums (use positional arguments) +- Skip tenant scoping for multi-tenant models + +### Git Operations - ALWAYS Confirm First +Before executing any permanent git or GitHub operation, **always ask for user confirmation**. This includes: +- `git commit` - Show the commit message and files to be committed +- `git push` - Show what will be pushed and to which remote/branch +- `gh pr create` - Show the PR title, description, and target branch +- `git reset --hard`, `git rebase`, or any destructive operations + +**Never run these commands without explicit user approval**, even if the user asked to "commit and push" or "create a PR". Always show what will happen first and wait for confirmation. + +### Do +- Use fixtures for test data +- Add YARD documentation to public methods +- Make UI responsive and dark-mode compatible +- Use Hotwire (Turbo + Stimulus) for JavaScript functionality +- Use the ruby ui components and app components instead of creating new ones. diff --git a/config/application.rb b/config/application.rb index b0f97a18..710cdcc8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,7 +14,7 @@ class Application < Rails::Application # 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]) + config.autoload_lib(ignore: %w[assets tasks generators]) # Configuration for the application, engines, and railties goes here. # diff --git a/lib/generators/ruby_ui/component/all_generator.rb b/lib/generators/ruby_ui/component/all_generator.rb index 2854f625..0195d648 100644 --- a/lib/generators/ruby_ui/component/all_generator.rb +++ b/lib/generators/ruby_ui/component/all_generator.rb @@ -1,5 +1,5 @@ begin - require "rails/generators/base" + require "rails/generators" rescue LoadError # Rails not available, skip generator definition end diff --git a/lib/generators/ruby_ui/component_generator.rb b/lib/generators/ruby_ui/component_generator.rb index 5bb72684..5dcba872 100644 --- a/lib/generators/ruby_ui/component_generator.rb +++ b/lib/generators/ruby_ui/component_generator.rb @@ -1,7 +1,7 @@ require_relative "javascript_utils" begin - require "rails/generators/base" + require "rails/generators" rescue LoadError # Rails not available, skip generator definition end diff --git a/lib/generators/ruby_ui/install/docs_generator.rb b/lib/generators/ruby_ui/install/docs_generator.rb index 5d5d7ac1..feef4ae9 100644 --- a/lib/generators/ruby_ui/install/docs_generator.rb +++ b/lib/generators/ruby_ui/install/docs_generator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true begin - require "rails/generators/base" + require "rails/generators" rescue LoadError # Rails not available, skip generator definition end diff --git a/lib/generators/ruby_ui/install/install_generator.rb b/lib/generators/ruby_ui/install/install_generator.rb index a8505fdb..7c0f585a 100644 --- a/lib/generators/ruby_ui/install/install_generator.rb +++ b/lib/generators/ruby_ui/install/install_generator.rb @@ -1,5 +1,5 @@ begin - require "rails/generators/base" + require "rails/generators" rescue LoadError # Rails not available, skip generator definition end From f67b0f9ef1082a589bc295f9ef24bef4f8aa9cd8 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 02:10:09 -0400 Subject: [PATCH 19/33] =?UTF-8?q?=F0=9F=8F=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/block_display.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/block_display.rb b/app/components/block_display.rb index b521ec1c..43e21794 100644 --- a/app/components/block_display.rb +++ b/app/components/block_display.rb @@ -173,7 +173,7 @@ def render_code_content def render_file_header @files.each_with_index do |file, index| figcaption( - class: "text-code-foreground [&_svg]:text-code-foreground relative flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 [&_svg]:size-4 [&_svg]:opacity-70 #{(index == 0) ? "" : "hidden"}", + class: "text-code-foreground [&_svg]:text-code-foreground relative flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 [&_svg]:size-4 [&_svg]:opacity-70 #{"hidden" unless index == 0}", data: { file_path: file[:path], block_code_viewer_target: "fileHeader" @@ -202,7 +202,7 @@ def render_file_header def render_code_body @files.each_with_index do |file, index| div( - class: "overflow-y-auto flex-1 min-h-0 #{(index == 0) ? "" : "hidden"}", + class: "overflow-y-auto flex-1 min-h-0 #{"hidden" unless index == 0}", data: { file_path: file[:path], block_code_viewer_target: "fileContent" From 2ca416f57b26a84abbff04f3161c170b9e822a36 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 12:54:58 -0400 Subject: [PATCH 20/33] =?UTF-8?q?=F0=9F=90=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index cf3774b8..2a51e73f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,8 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.7 -FROM quay.io/evl.ms/fullstaq-ruby:${RUBY_VERSION}-jemalloc-slim AS base +ARG RUBY_VERSION=4.0.1 +FROM ruby:${RUBY_VERSION} AS base LABEL fly_launch_runtime="rails" @@ -18,7 +18,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 postgresql-client && \ + apt-get install --no-install-recommends -y curl libvips sqlite3 postgresql-client && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment @@ -46,6 +46,8 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz # Install application gems COPY Gemfile Gemfile.lock ./.ruby-version vendor ./ +COPY ruby_ui.gemspec ./ +COPY lib ./lib RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile From ad24d5bd8e6f215d60b9d1547d35fdf155ab5298 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 12:57:06 -0400 Subject: [PATCH 21/33] =?UTF-8?q?=F0=9F=91=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 4 ++-- Gemfile.lock | 32 +++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index 6013dd65..9cee3c99 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem "lucide-rails", "0.7.3" # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] +gem "tzinfo-data", platforms: %i[windows jruby] # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false @@ -52,7 +52,7 @@ gem "thruster", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[mri mingw x64_mingw] + gem "debug", platforms: %i[mri windows] end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 518ed9a0..975422bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/basecamp/kamal.git - revision: a293409fcb64cdaec145b795ad01cba4403edbd7 + revision: 9c6252d0358e4a828400826f2d6d13d329a4b671 branch: main specs: - kamal (2.7.0) + kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -118,6 +118,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.23.0) @@ -142,8 +143,9 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.1) erubi (1.13.1) globalid (1.3.0) @@ -158,7 +160,7 @@ GEM reline (>= 0.4.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.18.1) + json (2.19.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -178,7 +180,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) net-imap (0.6.2) @@ -305,6 +308,13 @@ GEM sqlite3 (2.9.0) mini_portile2 (~> 2.8.0) sqlite3 (2.9.0-x86_64-linux-gnu) + sshkit (1.25.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.54.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -323,6 +333,8 @@ GEM tailwind_merge (1.3.2) sin_lru_redux (~> 2.5) thor (1.5.0) + thruster (0.1.19) + thruster (0.1.19-x86_64-linux) timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -347,7 +359,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS ruby @@ -359,6 +371,7 @@ DEPENDENCIES cssbundling-rails (= 1.4.3) debug jsbundling-rails (= 1.3.1) + kamal! lucide-rails (= 0.7.3) phlex! phlex-rails! @@ -366,19 +379,20 @@ DEPENDENCIES pry (= 0.16.0) puma (= 7.2.0) rails (= 8.1.2) - rouge (~> 4.7) + rouge (~> 4.6) selenium-webdriver seth_ruby_ui! sqlite3 (= 2.9.0) standard stimulus-rails (= 1.3.4) tailwind_merge (~> 1.3.2) + thruster turbo-rails (= 2.0.20) tzinfo-data web-console RUBY VERSION - ruby 4.0.1p0 + ruby 4.0.1p0 BUNDLED WITH - 4.0.7 + 4.0.7 From db769f5fcc8029a2228f8fe7a365b8fbe96c7e78 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 12:59:17 -0400 Subject: [PATCH 22/33] =?UTF-8?q?=F0=9F=8E=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kamal/secrets | 4 ++-- config/credentials/production.yml.enc | 2 +- config/deploy.yml | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.kamal/secrets b/.kamal/secrets index ca72891b..c39adcbf 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -3,8 +3,8 @@ # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. # Option 1: Read secrets from the environment -KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME -KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +# KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Option 2: Read secrets via a command RAILS_MASTER_KEY=$(cat config/credentials/production.key) diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index 7651e988..ae4e8d93 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -V8SNuyxE0Iq+Yq161KEN/j3hjKfRN68Q81rARXyEOeFKNPw7o/SktsjnFSg9jDgZ4692i8RZiz5h63ZcLe12+6Pd6lA2gNOYv8aGUdl686JnDDL92PLC3ZOMfZx1JGD0G33SZumqxrVgpMLgpY5FKLEGy09jNHEdD9mYv3siJKU89xd0pdKTCoIbpcVXfEfuPyuul8hRi8VJjPLoIeQnXTSNNVNlvhVqFAEDIhPoilhqbxSlK8uSJPfAlUCzeU3A5x1haKDR3grU324jWkQX5fSO7JRXc2MqJxVNu/npX6CWaXH/NTVmbPi1YmcpI8LbROzfDLHx5ejH1KocKgIGbJss6Mo9zZZHD1xY4IiEQT+AofOjpwapnFhFvcuNMZImTaczxICVcJdM+EffipFUNuA2QGMX--SXsZ0psbmaVD96/1--lU3yc2xi8ZE/Mgo31zKzKA== \ No newline at end of file +zGRHLfjzX++JAoqNQwf2VClxciN8Z738wxBeGE3zTkZjMEXRKaxLadYdJBa785iOyvirWl8XSBdB4RrKrR9nPSciwt/pG4r05OsZssx4d2WUzSQTkc03z45rw8x+n/XfdNB9ODl4anrKUkgngbMD2EwlyZO7SkWmxu09z7LsK3c1AKYzoPWptu4HGusAwARnmk9iN9D0KrccKFALe+r/R2+Siq5PC6AO8+5FKkMmMQUPD01xI3cSHqQnef7d1526ajcoTlARY+QbkdaXl1OV4e1OzkD7SpYysdHDsG4F45Xptp70IWQlQfLhIbcJpKNtFS/7wsHq5VDqVSyn7dx1BktH+umDU8G6sOt3l205e6UkytrUb/dVaReFnNoxrIfPg6qtLwOnFOpPpq6IjXZ+QqKV7U09nTGLGOchG/6Oqime+OMd0zDZq89J04OkGWzpb2v4xrXE8HKz6fDJ55B2VsrH7wYcmKiqN1OySyfMeuZn5VRZcAJa6xFj--iqfNOy+25dikJ3Bl--m1Rw8BtUjT2I/xGFerfSUg== \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml index e5c0473a..ec8d36bd 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -19,7 +19,7 @@ servers: # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. proxy: # ssl: true - host: next.rubyui.com + host: seth.rubyui.com # Proxy connects to your container on port 80 by default. app_port: 3000 @@ -27,13 +27,13 @@ proxy: registry: # Specify the registry server, if you're not using Docker Hub # server: registry.digitalocean.com / ghcr.io / ... - # server: ghcr.io - username: - - KAMAL_REGISTRY_USERNAME + server: localhost:5000 + # username: + # - KAMAL_REGISTRY_USERNAME # Always use an access token rather than real password (pulled from .kamal/secrets). - password: - - KAMAL_REGISTRY_PASSWORD + # password: + # - KAMAL_REGISTRY_PASSWORD # Configure builder setup. builder: From 3a234ff7892f6e373c30f6ffbe573cc4578017ed Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 13:25:33 -0400 Subject: [PATCH 23/33] =?UTF-8?q?=F0=9F=8C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 66 ++++++++++++++++++++++++++++++++++++ .gitignore | 4 +++ .kamal/secrets | 12 ++++--- config/deploy.yml | 19 +++++++---- 4 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..cc74adfc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Deploy with Kamal + +on: + push: + branches: + - main + workflow_dispatch: # Allow manual trigger + +jobs: + deploy: + runs-on: ubuntu-latest + + # Only deploy if tests pass + needs: [] + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0.1' + bundler-cache: true + + - name: Install Kamal + run: gem install kamal + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H 45.55.81.54 >> ~/.ssh/known_hosts + + - name: Create Kamal secrets file + run: | + mkdir -p .kamal + cat << EOF > .kamal/secrets + RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }} + KAMAL_REGISTRY_USERNAME=${{ github.actor }} + KAMAL_REGISTRY_PASSWORD=${{ secrets.GITHUB_TOKEN }} + EOF + + - name: Deploy with Kamal + env: + KAMAL_REGISTRY_SERVER: ghcr.io + SSH_AUTH_SOCK: /tmp/ssh_agent.sock + run: | + ssh-agent -a $SSH_AUTH_SOCK > /dev/null + ssh-add ~/.ssh/deploy_key + kamal deploy diff --git a/.gitignore b/.gitignore index 011574f2..cc174aae 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ yarn-error.log # Pnpm .pnpm-store + +# Ignore key files for decrypting credentials and more. +/config/credentials/*.key + diff --git a/.kamal/secrets b/.kamal/secrets index c39adcbf..b5fa9055 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -2,12 +2,14 @@ # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. -# Option 1: Read secrets from the environment -# KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME -# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD - -# Option 2: Read secrets via a command +# Rails master key RAILS_MASTER_KEY=$(cat config/credentials/production.key) +echo "RAILS_MASTER_KEY=$RAILS_MASTER_KEY" +# Registry credentials +# For local development with GHCR: set these environment variables or use gh CLI +# For local development with localhost registry: these are not needed +KAMAL_REGISTRY_USERNAME=${KAMAL_REGISTRY_USERNAME} +KAMAL_REGISTRY_PASSWORD=${KAMAL_REGISTRY_PASSWORD} # Option 3: Read secrets via kamal secrets helpers # These will handle logging in and fetching the secrets in as few calls as possible diff --git a/config/deploy.yml b/config/deploy.yml index ec8d36bd..0b9723fe 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,8 +1,10 @@ +<% require "dotenv"; Dotenv.load(".env") %> + # Name of your application. Used to uniquely configure containers. service: rubyui-web # Name of the container image. -image: rh63e/rubyui-web +image: sethhorsley/seth-rubyui-web # Deploy to these servers. servers: @@ -26,14 +28,15 @@ proxy: # Credentials for your image host. registry: # Specify the registry server, if you're not using Docker Hub - # server: registry.digitalocean.com / ghcr.io / ... - server: localhost:5000 - # username: - # - KAMAL_REGISTRY_USERNAME + # Use GHCR for CI/CD, or localhost:5001 for local development + # Set KAMAL_REGISTRY_SERVER environment variable to override + server: ghcr.io + username: + - KAMAL_REGISTRY_USERNAME # Always use an access token rather than real password (pulled from .kamal/secrets). - # password: - # - KAMAL_REGISTRY_PASSWORD + password: + - KAMAL_REGISTRY_PASSWORD # Configure builder setup. builder: @@ -49,6 +52,8 @@ env: # DB_HOST: 192.168.0.2 secret: - RAILS_MASTER_KEY + - KAMAL_REGISTRY_USERNAME + - KAMAL_REGISTRY_PASSWORD # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: # "bin/kamal app logs -r job" will tail logs from the first server in the job section. From 1491e7cc47391d936e8a3af27c7c92a90a926451 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 13:26:06 -0400 Subject: [PATCH 24/33] =?UTF-8?q?=F0=9F=93=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/deploy.yml b/config/deploy.yml index 0b9723fe..1b38e404 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,5 +1,3 @@ -<% require "dotenv"; Dotenv.load(".env") %> - # Name of your application. Used to uniquely configure containers. service: rubyui-web From 01b35f93b720819ef5d151070a688afbcc4081ee Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 13:51:24 -0400 Subject: [PATCH 25/33] =?UTF-8?q?=E2=9B=BA=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .kamal/secrets | 10 +++++----- bin/kamal | 9 +++++++++ config/deploy.yml | 9 ++++----- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100755 bin/kamal diff --git a/.gitignore b/.gitignore index cc174aae..05050541 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ yarn-error.log # Ignore key files for decrypting credentials and more. /config/credentials/*.key +.env diff --git a/.kamal/secrets b/.kamal/secrets index b5fa9055..4eaef68b 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -4,12 +4,12 @@ # Rails master key RAILS_MASTER_KEY=$(cat config/credentials/production.key) -echo "RAILS_MASTER_KEY=$RAILS_MASTER_KEY" + # Registry credentials -# For local development with GHCR: set these environment variables or use gh CLI -# For local development with localhost registry: these are not needed -KAMAL_REGISTRY_USERNAME=${KAMAL_REGISTRY_USERNAME} -KAMAL_REGISTRY_PASSWORD=${KAMAL_REGISTRY_PASSWORD} +# These should be set in your .env file or environment +# KAMAL_REGISTRY_USERNAME and KAMAL_REGISTRY_PASSWORD are read from ENV +KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Option 3: Read secrets via kamal secrets helpers # These will handle logging in and fetching the secrets in as few calls as possible diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 00000000..a0fee126 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Load .env file if it exists +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# Run kamal with all arguments +bundle exec kamal "$@" diff --git a/config/deploy.yml b/config/deploy.yml index 1b38e404..095b9adc 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -2,7 +2,7 @@ service: rubyui-web # Name of the container image. -image: sethhorsley/seth-rubyui-web +image: rh63e/rubyui-web # Deploy to these servers. servers: @@ -26,9 +26,10 @@ proxy: # Credentials for your image host. registry: # Specify the registry server, if you're not using Docker Hub - # Use GHCR for CI/CD, or localhost:5001 for local development # Set KAMAL_REGISTRY_SERVER environment variable to override - server: ghcr.io + # For local: use localhost:5001 or registry.docker.io (Docker Hub) + # For CI/CD: use ghcr.io (GitHub Container Registry) + server: registry.docker.io username: - KAMAL_REGISTRY_USERNAME @@ -50,8 +51,6 @@ env: # DB_HOST: 192.168.0.2 secret: - RAILS_MASTER_KEY - - KAMAL_REGISTRY_USERNAME - - KAMAL_REGISTRY_PASSWORD # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: # "bin/kamal app logs -r job" will tail logs from the first server in the job section. From bcd0806dd95a5ecebb1b69669c446cf1a3291170 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 13:54:55 -0400 Subject: [PATCH 26/33] =?UTF-8?q?=F0=9F=98=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/shared/logo.rb | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/components/shared/logo.rb b/app/components/shared/logo.rb index a12f7ff2..08ecb8e8 100644 --- a/app/components/shared/logo.rb +++ b/app/components/shared/logo.rb @@ -4,15 +4,21 @@ module Components module Shared class Logo < Components::Base def view_template - a(href: root_url, class: "mr-6 flex items-center space-x-2") do - Heading(level: 2, class: "flex items-center pb-0 border-0") { - img(src: image_url("logo.svg"), class: "h-4 block dark:hidden") - img(src: image_url("logo_dark.svg"), class: "h-4 hidden dark:block") - span(class: "sr-only") { "RubyUI" } - Badge(variant: :amber, size: :sm, class: "ml-2 whitespace-nowrap") { "1.0" } - } + a(href: root_url, class: 'mr-6 flex items-center space-x-2') do + Heading(level: 2, class: 'flex items-center pb-0 border-0') do + img(src: image_url('logo.svg'), class: 'h-4 block dark:hidden') + img(src: image_url('logo_dark.svg'), class: 'h-4 hidden dark:block') + span(class: 'sr-only') { 'RubyUI' } + Badge(variant: :amber, size: :sm, class: 'ml-2 whitespace-nowrap') { commit_hash } + end end end + + private + + def commit_hash + @commit_hash ||= `git rev-parse --short HEAD`.strip + end end end end From cc1263063a9f69245e5aeadb82048e0c8de48848 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 13:56:05 -0400 Subject: [PATCH 27/33] =?UTF-8?q?=F0=9F=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/shared/logo.rb | 12 ++++++------ app/components/shared/navbar.rb | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/components/shared/logo.rb b/app/components/shared/logo.rb index 08ecb8e8..e784a3b7 100644 --- a/app/components/shared/logo.rb +++ b/app/components/shared/logo.rb @@ -4,12 +4,12 @@ module Components module Shared class Logo < Components::Base def view_template - a(href: root_url, class: 'mr-6 flex items-center space-x-2') do - Heading(level: 2, class: 'flex items-center pb-0 border-0') do - img(src: image_url('logo.svg'), class: 'h-4 block dark:hidden') - img(src: image_url('logo_dark.svg'), class: 'h-4 hidden dark:block') - span(class: 'sr-only') { 'RubyUI' } - Badge(variant: :amber, size: :sm, class: 'ml-2 whitespace-nowrap') { commit_hash } + a(href: root_url, class: "mr-6 flex items-center space-x-2") do + Heading(level: 2, class: "flex items-center pb-0 border-0") do + img(src: image_url("logo.svg"), class: "h-4 block dark:hidden") + img(src: image_url("logo_dark.svg"), class: "h-4 hidden dark:block") + span(class: "sr-only") { "RubyUI" } + Badge(variant: :amber, size: :sm, class: "ml-2 whitespace-nowrap") { commit_hash } end end end diff --git a/app/components/shared/navbar.rb b/app/components/shared/navbar.rb index aa8f34da..9142b1c5 100644 --- a/app/components/shared/navbar.rb +++ b/app/components/shared/navbar.rb @@ -79,7 +79,7 @@ def twitter_link end def github_link - Link(href: "https://github.com/PhlexUI/phlex_ui", variant: :ghost, icon: true) do + Link(href: "https://github.com/sethhorsley/ruby-ui-web", variant: :ghost, icon: true) do github_icon end end From 35960f05923cd2a149fa1c59fc81411752df0b92 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:03:45 -0400 Subject: [PATCH 28/33] fix deploy creds --- .github/workflows/deploy.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cc74adfc..d956797d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,12 +33,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry + - name: Login to Docker Hub uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ secrets.KAMAL_REGISTRY_USERNAME }} + password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} - name: Set up SSH run: | @@ -52,13 +51,12 @@ jobs: mkdir -p .kamal cat << EOF > .kamal/secrets RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }} - KAMAL_REGISTRY_USERNAME=${{ github.actor }} - KAMAL_REGISTRY_PASSWORD=${{ secrets.GITHUB_TOKEN }} + KAMAL_REGISTRY_USERNAME=${{ secrets.KAMAL_REGISTRY_USERNAME }} + KAMAL_REGISTRY_PASSWORD=${{ secrets.KAMAL_REGISTRY_PASSWORD }} EOF - name: Deploy with Kamal env: - KAMAL_REGISTRY_SERVER: ghcr.io SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | ssh-agent -a $SSH_AUTH_SOCK > /dev/null From 33b1b0c00d77f6255f0c61eb9415e151b42fbe9a Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:05:53 -0400 Subject: [PATCH 29/33] fix url --- config/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/deploy.yml b/config/deploy.yml index 095b9adc..a261f10b 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -29,7 +29,7 @@ registry: # Set KAMAL_REGISTRY_SERVER environment variable to override # For local: use localhost:5001 or registry.docker.io (Docker Hub) # For CI/CD: use ghcr.io (GitHub Container Registry) - server: registry.docker.io + server: registry.hub.docker.com username: - KAMAL_REGISTRY_USERNAME From 5cb78fe08b21ac250d88a1a1a5b1f826ce8a79d5 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:15:30 -0400 Subject: [PATCH 30/33] fix host for kamal deploy --- .github/workflows/deploy.yml | 3 ++- config/deploy.yml | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d956797d..17a6266a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,7 +44,8 @@ jobs: mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H 45.55.81.54 >> ~/.ssh/known_hosts + ssh-keyscan -H 45.55.81.54 > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Create Kamal secrets file run: | diff --git a/config/deploy.yml b/config/deploy.yml index a261f10b..e1919ccc 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -65,8 +65,10 @@ aliases: # Use a different ssh user than root # -# ssh: -# user: app +ssh: + keys: ["~/.ssh/deploy_key"] + options: + StrictHostKeyChecking: accept-new # Use a persistent storage volume. # From 1ee131a42ea1da936e9337ea55929bc9ef55c90e Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:24:31 -0400 Subject: [PATCH 31/33] 2nd try for kamal to work --- .github/workflows/deploy.yml | 6 ++++++ config/deploy.yml | 6 ++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 17a6266a..9f501e25 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,12 @@ jobs: chmod 600 ~/.ssh/deploy_key ssh-keyscan -H 45.55.81.54 > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts + cat << EOF > ~/.ssh/config + Host 45.55.81.54 + StrictHostKeyChecking accept-new + IdentityFile ~/.ssh/deploy_key + EOF + chmod 600 ~/.ssh/config - name: Create Kamal secrets file run: | diff --git a/config/deploy.yml b/config/deploy.yml index e1919ccc..a261f10b 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -65,10 +65,8 @@ aliases: # Use a different ssh user than root # -ssh: - keys: ["~/.ssh/deploy_key"] - options: - StrictHostKeyChecking: accept-new +# ssh: +# user: app # Use a persistent storage volume. # From 9b9bf0198edc0960ea6d13db97c4cc8e9469226a Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:35:44 -0400 Subject: [PATCH 32/33] =?UTF-8?q?=F0=9F=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 32 ++++++++++++++------------------ config/deploy.yml | 6 ++++++ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f501e25..08f2d4e3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,28 +30,25 @@ jobs: - name: Install Kamal run: gem install kamal - - name: Set up Docker Buildx + - name: Set up Docker Buildx for cache uses: docker/setup-buildx-action@v3 + - name: Expose GitHub Runtime for cache + uses: crazy-max/ghaction-github-runtime@v3 + - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.KAMAL_REGISTRY_USERNAME }} password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} - - name: Set up SSH + - name: Set up SSH connection run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H 45.55.81.54 > ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts - cat << EOF > ~/.ssh/config - Host 45.55.81.54 - StrictHostKeyChecking accept-new - IdentityFile ~/.ssh/deploy_key - EOF - chmod 600 ~/.ssh/config + mkdir -p ~/.ssh && echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa + eval $(ssh-agent -s) && ssh-add ~/.ssh/id_rsa + ssh-keyscan 45.55.81.54 >> ~/.ssh/known_hosts + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - name: Create Kamal secrets file run: | @@ -63,9 +60,8 @@ jobs: EOF - name: Deploy with Kamal + run: bin/kamal deploy env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: | - ssh-agent -a $SSH_AUTH_SOCK > /dev/null - ssh-add ~/.ssh/deploy_key - kamal deploy + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} + KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }} diff --git a/config/deploy.yml b/config/deploy.yml index a261f10b..11d69cf5 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -40,6 +40,12 @@ registry: # Configure builder setup. builder: arch: amd64 + cache: + type: gha + options: mode=max + image: app-build-cache + secrets: + - RAILS_MASTER_KEY # Pass in additional build args needed for your Dockerfile. # args: # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %> From 8a0b9accb1381196924e202fb89eedc6094208d4 Mon Sep 17 00:00:00 2001 From: Seth Horsley Date: Mon, 9 Mar 2026 14:47:32 -0400 Subject: [PATCH 33/33] fix commit badge --- Dockerfile | 5 ++++- app/components/shared/logo.rb | 6 +++++- config/deploy.yml | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a51e73f..fe59b4a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=4.0.1 +ARG GIT_COMMIT_HASH=unknown FROM ruby:${RUBY_VERSION} AS base LABEL fly_launch_runtime="rails" @@ -22,10 +23,12 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment +ARG GIT_COMMIT_HASH ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" + BUNDLE_WITHOUT="development" \ + GIT_COMMIT_HASH="${GIT_COMMIT_HASH}" # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/app/components/shared/logo.rb b/app/components/shared/logo.rb index e784a3b7..4e00a9c8 100644 --- a/app/components/shared/logo.rb +++ b/app/components/shared/logo.rb @@ -17,7 +17,11 @@ def view_template private def commit_hash - @commit_hash ||= `git rev-parse --short HEAD`.strip + @commit_hash ||= ENV.fetch("GIT_COMMIT_HASH") do + `git rev-parse --short HEAD`.strip + rescue + "unknown" + end end end end diff --git a/config/deploy.yml b/config/deploy.yml index 11d69cf5..96def0d8 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -47,8 +47,8 @@ builder: secrets: - RAILS_MASTER_KEY # Pass in additional build args needed for your Dockerfile. - # args: - # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %> + args: + GIT_COMMIT_HASH: <%= `git rev-parse --short HEAD`.strip %> # Inject ENV variables into containers (secrets come from .kamal/secrets). #