├── log └── .keep ├── storage └── .keep ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep ├── vendor ├── .keep └── javascript │ └── .keep ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── public ├── favicon.ico ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── .ruby-version ├── app ├── assets │ ├── images │ │ └── .keep │ ├── config │ │ └── manifest.js │ └── stylesheets │ │ ├── application.css │ │ └── pico.css ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── blog.rb │ ├── user.rb │ └── post.rb ├── controllers │ ├── concerns │ │ ├── .keep │ │ ├── batchable.rb │ │ ├── resource.rb │ │ └── assignable.rb │ ├── sessions_controller.rb │ ├── users │ │ ├── sessions_controller.rb │ │ └── blogs_controller.rb │ ├── application_controller.rb │ ├── posts_controller.rb │ ├── blogs │ │ ├── posts_controller.rb │ │ └── batch │ │ │ └── posts_controller.rb │ ├── users_controller.rb │ └── blogs_controller.rb ├── views │ ├── layouts │ │ ├── mailer.text.erb │ │ ├── mailer.html.erb │ │ ├── application_layout.rb │ │ └── page_layout.rb │ ├── posts │ │ ├── new.rb │ │ ├── view.rb │ │ ├── edit.rb │ │ ├── form.rb │ │ ├── show.rb │ │ └── index.rb │ ├── components │ │ ├── application_component.rb │ │ ├── list_component.rb │ │ └── table_component.rb │ ├── application_form.rb │ ├── application_view.rb │ └── superview │ │ └── turbo.rb ├── helpers │ ├── blogs_helper.rb │ ├── posts_helper.rb │ ├── users_helper.rb │ ├── sessions_helper.rb │ ├── application_helper.rb │ ├── blogs │ │ └── posts_helper.rb │ ├── users │ │ ├── blogs_helper.rb │ │ └── sessions_helper.rb │ └── link_helpers.rb ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── mailers │ └── application_mailer.rb ├── javascript │ ├── application.js │ └── controllers │ │ ├── hello_controller.js │ │ ├── application.js │ │ └── index.js └── jobs │ └── application_job.rb ├── .rspec ├── Procfile.dev ├── bin ├── rake ├── importmap ├── rails ├── docker-entrypoint ├── setup └── bundle ├── config ├── dockerfile.yml ├── environment.rb ├── cable.yml ├── boot.rb ├── importmap.rb ├── initializers │ ├── filter_parameter_logging.rb │ ├── permissions_policy.rb │ ├── assets.rb │ ├── inflections.rb │ └── content_security_policy.rb ├── credentials.yml.enc ├── routes.rb ├── locales │ └── en.yml ├── application.rb ├── database.yml ├── storage.yml ├── puma.rb └── environments │ ├── test.rb │ ├── development.rb │ └── production.rb ├── config.ru ├── Rakefile ├── db ├── migrate │ ├── 20230320162030_create_users.rb │ ├── 20230320162044_create_blogs.rb │ └── 20230320162105_create_posts.rb ├── seeds.rb └── schema.rb ├── .gitattributes ├── fly.toml ├── .dockerignore ├── Guardfile ├── .gitignore ├── MIT-LICENSE ├── Dockerfile ├── Gemfile ├── spec ├── rails_helper.rb ├── spec_helper.rb └── views │ └── phlex │ └── superform_spec.rb ├── README.md └── Gemfile.lock /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.1 2 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server --port=$PORT 2 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/helpers/blogs_helper.rb: -------------------------------------------------------------------------------- 1 | module BlogsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/posts_helper.rb: -------------------------------------------------------------------------------- 1 | module PostsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/blogs/posts_helper.rb: -------------------------------------------------------------------------------- 1 | module Blogs::PostsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/users/blogs_helper.rb: -------------------------------------------------------------------------------- 1 | module Users::BlogsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/users/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module Users::SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /config/dockerfile.yml: -------------------------------------------------------------------------------- 1 | # generated by dockerfile-rails 2 | 3 | --- 4 | options: 5 | label: 6 | fly_launch_runtime: rails 7 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/views/posts/new.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class New < View 3 | def title = "New Post" 4 | 5 | def template 6 | render Form.new(Post.new) 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../../javascript .js 4 | //= link_tree ../../../vendor/javascript .js 5 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | def destroy 3 | session[:user_id] = nil 4 | redirect_to root_url 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: litecable 3 | 4 | test: 5 | adapter: test 6 | 7 | staging: 8 | adapter: litecable 9 | 10 | production: 11 | adapter: litecable 12 | -------------------------------------------------------------------------------- /app/views/posts/view.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class View < ApplicationView 3 | attr_writer :post 4 | 5 | turbo method: :morph do 6 | stream_from @post, @current_user 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /app/models/blog.rb: -------------------------------------------------------------------------------- 1 | class Blog < ApplicationRecord 2 | belongs_to :user, required: true, touch: true 3 | has_many :posts 4 | 5 | 6 | validates :title, presence: true 7 | 8 | broadcasts_refreshes 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/users/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class Users::SessionsController < ApplicationController 2 | def create 3 | session[:user_id] = params.require(:user).permit(:id) 4 | redirect_to root_url 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/javascript/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_many :blogs 3 | has_many :posts 4 | 5 | validates :name, presence: true 6 | validates :email, presence: true 7 | 8 | broadcasts_refreshes 9 | end 10 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # If running the rails server then create or migrate existing database 4 | if [ "${*}" == "./bin/rails server" ]; then 5 | ./bin/rails db:prepare db:seed 6 | fi 7 | 8 | exec "${@}" 9 | -------------------------------------------------------------------------------- /app/views/posts/edit.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class Edit < View 3 | def title = @post.title 4 | def subtitle = show(@post.blog, :title) 5 | 6 | def template 7 | render Form.new(@post) 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /db/migrate/20230320162030_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | t.string :email 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /db/migrate/20230320162044_create_blogs.rb: -------------------------------------------------------------------------------- 1 | class CreateBlogs < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :blogs do |t| 4 | t.string :title 5 | t.references :user, null: false, foreign_key: true 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Assignable 3 | include Superview::Actions 4 | include Resource 5 | 6 | layout false 7 | 8 | def current_user 9 | @current_user ||= User.find_or_create_by!(email: "somebody@example.com", name: "Somebody") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application", preload: true 4 | pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true 5 | pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true 6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true 7 | pin_all_from "app/javascript/controllers", under: "controllers" 8 | -------------------------------------------------------------------------------- /db/migrate/20230320162105_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :posts do |t| 4 | t.string :title 5 | t.text :content 6 | t.datetime :publish_at 7 | 8 | t.references :user, null: false, foreign_key: true 9 | t.references :blog, null: false, foreign_key: true 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /app/views/components/application_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationComponent < Phlex::HTML 4 | include Phlex::Rails::Helpers::Routes 5 | include LinkHelpers 6 | include Superview::Turbo::Helpers 7 | 8 | if Rails.env.development? 9 | def before_template 10 | comment { "Before #{self.class.name}" } 11 | end 12 | end 13 | 14 | def after_template 15 | turbo if respond_to? :turbo 16 | super 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/components/list_component.rb: -------------------------------------------------------------------------------- 1 | class ListComponent < ApplicationComponent 2 | include Phlex::DeferredRender 3 | 4 | def initialize(items:) 5 | @items = items 6 | end 7 | 8 | def template(&) 9 | @items.each do |item| 10 | li { @item_template.call(item) } 11 | end 12 | end 13 | 14 | def item(&item_template) 15 | @item_template = item_template 16 | end 17 | 18 | def around_template(&) 19 | ol { super } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | PlulxZUW/VB5E5mv8EnHPC/ugiLnAQdu9H61uEi4GvGbT7mNMjZsSqEagUsq64iuOkC0HcDw0KjRFFrFmKXOOc1u4/xRT37w3xelLUOcOPe+08y3YiG1Xpz+fEeMrwdn0iGsv/YkIKBPBjg3ZlBqddTrS5yXEr5ZFdj20IQ13LHJ8a+802bXMd4lV7HfvoDJx3rR7FBQ9nPRSMTBL6Qy6ADH9bW5WkhJGhLy2PoV8vxswRcHQ+9GDBGvFQQrLVX6ElDIRO3IMF7Qe6Wr7Ytyjo1uOLUP47sDTQNMs+bMYB3vj+K2xs2mM8GBGo8Y85j3/0i+LTND5cZH3NGnk77QfpsU31gjj4L2naG1Tc2yiKHKDUCb/vXBL02n+odK6gmr8aq1Aog5i3IkC/nGC3U/Dus5Y8XmqS+YbwbK--GVDB133IEzqOLjyk--9AhQfCNISfjwFj/kxym+kw== -------------------------------------------------------------------------------- /app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | resources :posts, from: :current_user 3 | 4 | # Demonstration of including views that have been moved 5 | # from the controller into their own files. This makes sense 6 | # if your views need to be shared between multiple controllers 7 | # or if your project prefers to have views as their own files. 8 | include Posts 9 | 10 | private 11 | 12 | def destroyed_url 13 | @post.blog 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | belongs_to :user, touch: true 3 | belongs_to :blog, touch: true 4 | 5 | validates :title, presence: true 6 | 7 | attribute :publish_at, Inputomatic::DateTime.new 8 | 9 | broadcasts_refreshes 10 | 11 | def status 12 | status = if publish_at.nil? 13 | "Draft" 14 | elsif publish_at > Time.current 15 | "Scheduled" 16 | else 17 | "Published" 18 | end 19 | 20 | ActiveSupport::StringInquirer.new(status) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/views/posts/form.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class Form < ApplicationForm 3 | def template 4 | labeled field(:blog_id).select Blog.select(:id, :title), nil 5 | # Same thing as above, but multiple lines. Useful for optgroups. 6 | # labeled field(:blog).select do 7 | # _1.options(Blog.select(:id, :title)) 8 | # _1.blank_option 9 | # end 10 | 11 | labeled field(:title).input.focus 12 | labeled field(:publish_at).input 13 | labeled field(:content).textarea(rows: 6) 14 | 15 | submit 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | def batch(*actions, **kwargs) 2 | collection do 3 | namespace :batch do 4 | get :index 5 | post :update 6 | actions.each do |action| 7 | post action 8 | end 9 | end 10 | end 11 | end 12 | 13 | Rails.application.routes.draw do 14 | resources :users, except: :destroy do 15 | nest :blogs 16 | create :session 17 | end 18 | 19 | resources :blogs do 20 | nest :posts do 21 | batch :delete, :publish, :unpublish 22 | end 23 | end 24 | 25 | resources :posts 26 | 27 | resources :sessions 28 | 29 | root to: "blogs#index" 30 | end 31 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "controllers/application" 4 | 5 | // Eager load all controllers defined in the import map under controllers/**/*_controller 6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 7 | eagerLoadControllersFrom("controllers", application) 8 | 9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) 10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" 11 | // lazyLoadControllersFrom("controllers", application) 12 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) 7 | # Character.create(name: "Luke", movie: movies.first) 8 | 9 | User.create!(name: "Bob Law", email: "bob.law@example.com").tap do |user| 10 | user.blogs.create!(title: "Law Blog").tap do |blog| 11 | (1..10).each do |n| 12 | blog.posts.create! title: "The #{n.ordinalize} Post", user: user 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /app/controllers/blogs/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class Blogs::PostsController < ApplicationController 2 | resources :posts, through: :blogs, from: :current_user 3 | before_action :assign_user, only: %i[new create] 4 | 5 | class New < ApplicationView 6 | attr_accessor :current_user, :blog, :post 7 | turbo method: :morph do 8 | stream_from @blog, @blog.user 9 | end 10 | 11 | def title = "Create a new post" 12 | def subtitle = show(@blog, :title) 13 | 14 | def template(&) 15 | render PostsController::Form.new(@post) 16 | end 17 | end 18 | 19 | private 20 | 21 | def assign_user 22 | @post.user = current_user 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/layouts/application_layout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationLayout < ApplicationComponent 4 | include Phlex::Rails::Layout 5 | 6 | def initialize(title:, turbo:) 7 | @title = title 8 | @turbo = turbo 9 | end 10 | 11 | def template(&) 12 | doctype 13 | 14 | html do 15 | head do 16 | title(&@title) 17 | meta name: "viewport", content: "width=device-width,initial-scale=1" 18 | csp_meta_tag 19 | csrf_meta_tags 20 | stylesheet_link_tag "application", data_turbo_track: "reload" 21 | javascript_importmap_tags 22 | render @turbo 23 | end 24 | 25 | body(&) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | inflect.acronym "DOM" 16 | end 17 | -------------------------------------------------------------------------------- /app/views/posts/show.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class Show < View 3 | def title = @post.title 4 | def subtitle = show(@post.blog, :title) 5 | 6 | def template 7 | table do 8 | tbody do 9 | tr do 10 | th { "Status" } 11 | td { @post.status } 12 | end 13 | tr do 14 | th { "Publish at" } 15 | td { @post.publish_at&.to_formatted_s(:long) } 16 | end 17 | tr do 18 | th { "Content" } 19 | td do 20 | article { @post.content } 21 | end 22 | end 23 | end 24 | end 25 | nav do 26 | edit(@post, role: "button") 27 | delete(@post) 28 | end 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /app/controllers/users/blogs_controller.rb: -------------------------------------------------------------------------------- 1 | class Users::BlogsController < ApplicationController 2 | resources :blogs, from: :current_user 3 | 4 | class New < ApplicationView 5 | attr_writer :blog 6 | 7 | def title = "Create blog" 8 | def subtitle = "You'll be writing awesome stuff in no time" 9 | 10 | def template 11 | render BlogsController::Form.new(@blog) 12 | end 13 | end 14 | 15 | class Index < ApplicationView 16 | attr_writer :blogs, :current_user 17 | 18 | def title = "#{@current_user.name}'s Blogs" 19 | 20 | def template(&) 21 | list(@blogs) do |blog| 22 | show(blog, :title) 23 | end 24 | nav do 25 | create(@current_user.blogs, role: "button") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for oxidizer-demo on 2023-06-07T16:35:27-07:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "oxidizer-demo" 7 | primary_region = "sjc" 8 | kill_signal = "SIGINT" 9 | kill_timeout = "5s" 10 | 11 | [experimental] 12 | auto_rollback = true 13 | 14 | [env] 15 | PRIMARY_REGION = "sjc" 16 | 17 | [http_service] 18 | internal_port = 3000 19 | force_https = true 20 | auto_stop_machines = true 21 | auto_start_machines = true 22 | min_machines_running = 1 23 | processes = ["app"] 24 | 25 | [[statics]] 26 | guest_path = "/rails/public" 27 | url_prefix = "/" 28 | 29 | [mounts] 30 | source="oxidizer_demo_litestack_data" 31 | destination="/data" -------------------------------------------------------------------------------- /app/views/application_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationForm < Superform::Rails::Form 4 | class LabelField < Superform::Rails::Components::LabelComponent 5 | def template(&content) 6 | content ||= Proc.new { field.title } 7 | label(**attributes) { strong(&content) } 8 | end 9 | end 10 | 11 | class Field < Field 12 | def label(**attributes) 13 | LabelField.new(self, attributes: attributes) 14 | end 15 | end 16 | 17 | # def field(name) 18 | # if reflection = @model.class.reflect_on_association(name) 19 | # name = reflection.foreign_key 20 | # end 21 | # super(name) 22 | # end 23 | 24 | def labeled(component, &) 25 | render component.field.label 26 | render component, & 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/posts/index.rb: -------------------------------------------------------------------------------- 1 | module Posts 2 | class Index < ApplicationView 3 | attr_writer :posts, :current_user 4 | 5 | turbo method: :morph do 6 | stream_from @posts, @current_user 7 | end 8 | 9 | def title = "#{@current_user.name}'s Posts" 10 | 11 | def template 12 | render TableComponent.new(items: @posts) do |table| 13 | table.column("Title") { show(_1, :title) } 14 | table.column do |column| 15 | # Titles might not always be text, so we need to handle rendering 16 | # Phlex markup within. 17 | column.title do 18 | link_to(user_blogs_path(@current_user)) { "Blogs" } 19 | end 20 | column.item { show(_1.blog, :title) } 21 | end 22 | end 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /app/views/application_view.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationView < ApplicationComponent 4 | include LinkHelpers 5 | 6 | attr_writer :resource, :resources 7 | attr_reader :forms 8 | 9 | def title = nil 10 | def subtitle = nil 11 | 12 | def list(collection, &item_template) 13 | render ListComponent.new(items: collection) do |list| 14 | list.item(&item_template) 15 | end 16 | end 17 | 18 | def initialize(...) 19 | @forms = [] 20 | @turbo_meta_tags = self.class.turbo_meta_tags 21 | super(...) 22 | end 23 | 24 | def render(view, ...) 25 | @forms.push view if view.is_a? ApplicationForm 26 | super(view, ...) 27 | end 28 | 29 | def around_template(&) 30 | render PageLayout.new(title: proc { title }, subtitle: proc { subtitle }, turbo: @turbo_meta_tags) do 31 | super(&) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | 6 | # Ignore bundler config. 7 | /.bundle 8 | 9 | # Ignore all default key files. 10 | /config/master.key 11 | /config/credentials/*.key 12 | 13 | # Ignore all environment files. 14 | /.env* 15 | !/.env.example 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/* 19 | /tmp/* 20 | !/log/.keep 21 | !/tmp/.keep 22 | 23 | # Ignore pidfiles, but keep the directory. 24 | /tmp/pids/* 25 | !/tmp/pids/ 26 | !/tmp/pids/.keep 27 | 28 | # Ignore storage (uploaded files in development and any SQLite databases). 29 | /storage/* 30 | !/storage/.keep 31 | /tmp/storage/* 32 | !/tmp/storage/ 33 | !/tmp/storage/.keep 34 | 35 | # Ignore assets. 36 | /node_modules/ 37 | /app/assets/builds/* 38 | !/app/assets/builds/.keep 39 | /public/assets 40 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | ## Uncomment and set this to only include directories you want to watch 5 | # directories %w(app lib config test spec features) \ 6 | # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")} 7 | 8 | ## Note: if you are using the `directories` clause above and you are not 9 | ## watching the project directory ('.'), then you will want to move 10 | ## the Guardfile to a watched dir and symlink it back, e.g. 11 | # 12 | # $ mkdir config 13 | # $ mv Guardfile config/ 14 | # $ ln -s config/Guardfile . 15 | # 16 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 17 | 18 | # Watches all Ruby files in a project. 19 | # 20 | # The `require:` parameter is configured for a Rails project. If using it for 21 | # a gem, change the require path to whatever loads a Zeitwerk::Autoloader. 22 | guard :zeitwerk, require: "./config/application.rb" do 23 | watch(/.*\.rb$/) 24 | end 25 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-* 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | # Ignore uploaded files in development. 26 | /storage/* 27 | !/storage/.keep 28 | /tmp/storage/* 29 | !/tmp/storage/ 30 | !/tmp/storage/.keep 31 | 32 | /public/assets 33 | 34 | # Ignore master key for decrypting credentials and more. 35 | /config/master.key 36 | 37 | # Ignore default Litestack SQLite databases. 38 | /db/**/*.sqlite3 39 | /db/**/*.sqlite3-* 40 | 41 | .env -------------------------------------------------------------------------------- /app/views/layouts/page_layout.rb: -------------------------------------------------------------------------------- 1 | class PageLayout < ApplicationLayout 2 | def initialize(subtitle: nil, **kwargs) 3 | super **kwargs 4 | @subtitle = subtitle 5 | end 6 | 7 | def template(&) 8 | super do 9 | header(class: "container") do 10 | if @title and @subtitle 11 | hgroup do 12 | h1(&@title) 13 | h2(&@subtitle) 14 | end 15 | else 16 | h1 { @title } 17 | end 18 | end 19 | main(class: "container", &) 20 | footer(class: "container") do 21 | small do 22 | plain "This demo application is erased on every deploy. Read more about it at " 23 | link_to("https://fly.io/ruby-dispatch/component-driven-development-on-rails-with-phlex") do 24 | "Component Driven Development" 25 | end 26 | plain " or view the " 27 | link_to("https://github.com/rubymonolith/demo") { "source code on Github" } 28 | plain "." 29 | end 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Resourcerer 10 | class Application < Rails::Application 11 | config.autoload_paths << "#{root}/app/views" 12 | config.autoload_paths << "#{root}/app/views/layouts" 13 | config.autoload_paths << "#{root}/app/views/components" 14 | config.autoload_paths << "#{root}/lib/generators" 15 | 16 | # Initialize configuration defaults for originally generated Rails version. 17 | config.load_defaults 7.0 18 | 19 | # Configuration for the application, engines, and railties goes here. 20 | # 21 | # These settings can be overridden in specific environments using the files 22 | # in config/environments, which are processed later. 23 | # 24 | # config.time_zone = "Central Time (US & Canada)" 25 | # config.eager_load_paths << Rails.root.join("extras") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | # `Litesupport.root.join("data.sqlite3")` stores 8 | # application data in the path `./db/#{Rails.env}/data.sqlite3` 9 | default: &default 10 | adapter: litedb 11 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 12 | timeout: 5000 13 | database: <%= Litesupport.root.join("data.sqlite3") %> 14 | 15 | development: 16 | <<: *default 17 | 18 | # Warning: The database defined as "test" will be erased and 19 | # re-generated from your development database when you run "rake". 20 | # Do not set this db to the same as development or production. 21 | test: 22 | <<: *default 23 | 24 | # Warning: Make sure your production database path is on a persistent 25 | # volume, otherwise your application data could be deleted between deploys. 26 | # 27 | # You may also set the Litesupport.root in production via the `LITESTACK_DATA_PATH` 28 | # environment variable. 29 | production: 30 | <<: *default 31 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Brad Gessler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /app/views/components/table_component.rb: -------------------------------------------------------------------------------- 1 | class TableComponent < ApplicationComponent 2 | include Phlex::DeferredRender 3 | 4 | class Column 5 | attr_accessor :title_template, :item_template 6 | 7 | def title(&block) 8 | @title_template = block 9 | end 10 | 11 | def item(&block) 12 | @item_template = block 13 | end 14 | 15 | def self.build(title:, &block) 16 | new.tap do |column| 17 | column.title { title } 18 | column.item(&block) 19 | end 20 | end 21 | end 22 | 23 | def initialize(items: []) 24 | @items = items 25 | @columns = [] 26 | end 27 | 28 | def template(&) 29 | table do 30 | thead do 31 | tr do 32 | @columns.each do |column| 33 | th(&column.title_template) 34 | end 35 | end 36 | end 37 | tbody do 38 | @items.each do |item| 39 | tr do 40 | @columns.each do |column| 41 | td { column.item_template.call(item) } 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | 49 | def column(title = nil, &block) 50 | @columns << if title 51 | Column.build(title: title, &block) 52 | else 53 | Column.new.tap(&block) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | resources :users 3 | 4 | class Form < ApplicationForm 5 | def template(&) 6 | labeled field(:name).input 7 | labeled field(:email).input 8 | submit 9 | end 10 | end 11 | 12 | class Index < ApplicationView 13 | attr_writer :users 14 | 15 | def title = "People" 16 | def subtitle = "Create users and sign in" 17 | 18 | def template(&) 19 | section do 20 | ul do 21 | @users.each do |user| 22 | li { show(user, :name) } 23 | end 24 | end 25 | create(@users, role: "button") 26 | end 27 | end 28 | end 29 | 30 | class Show < ApplicationView 31 | attr_writer :user 32 | 33 | def title = @user.name 34 | def subtitle = @user.email 35 | 36 | def template(&) 37 | list(@user.blogs) do |blog| 38 | show(blog, :title) 39 | end 40 | nav do 41 | create(@user.blogs, role: "button") 42 | edit(@user, role: "secondary") 43 | end 44 | end 45 | end 46 | 47 | class New < ApplicationView 48 | attr_writer :user 49 | 50 | def title = "Create user" 51 | 52 | def template(&) 53 | render Form.new(@user) 54 | end 55 | end 56 | 57 | class Edit < ApplicationView 58 | attr_writer :user 59 | 60 | def title = "Edit #{@user.name}" 61 | 62 | def template(&) 63 | render Form.new(@user) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/helpers/link_helpers.rb: -------------------------------------------------------------------------------- 1 | module LinkHelpers 2 | # Give us some sane link helpers to work with in Phlex. They kind 3 | # of mimic Rails helpers, but are "Phlexable". 4 | def link_to(target, **attributes, &) 5 | a(href: url_for(target), **attributes, &) 6 | end 7 | 8 | def show(model, attribute = nil, *args, **kwargs, &content) 9 | content ||= Proc.new { model.send(attribute) } if attribute 10 | link_to(model, *args, **kwargs, &content) 11 | end 12 | 13 | def edit(model, *args, **kwargs, &content) 14 | content ||= Proc.new { "Edit #{model.class.model_name}" } 15 | link_to([:edit, model], *args, **kwargs, &content) 16 | end 17 | 18 | def delete(model, *args, data: {}, **kwargs, &content) 19 | content ||= Proc.new { "Delete #{model.class.model_name}" } 20 | link_to(model, *args, data: data.merge("turbo-method": :delete), **kwargs, &content) 21 | end 22 | 23 | def create(scope = nil, *args, **kwargs, &content) 24 | target = if scope.respond_to? :proxy_association 25 | owner = scope.proxy_association.owner 26 | model = scope.proxy_association.reflection.klass.model_name 27 | element = scope.proxy_association.reflection.klass.model_name.element.to_sym 28 | [:new, owner, element] 29 | elsif scope.respond_to? :model 30 | model = scope.model 31 | [:new, model.model_name.element.to_sym] 32 | else 33 | model = scope 34 | [:new, scope] 35 | end 36 | 37 | content ||= Proc.new { "Create #{model}" } 38 | 39 | link_to(target, *args, **kwargs, &content) 40 | end 41 | end -------------------------------------------------------------------------------- /app/controllers/concerns/batchable.rb: -------------------------------------------------------------------------------- 1 | module Batchable 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :assign_selection 6 | end 7 | 8 | class Selection 9 | include ActiveModel::API 10 | attr_accessor :selected, :action, :scope 11 | 12 | def selected 13 | @selected ||= [] 14 | end 15 | 16 | def selected?(item = nil) 17 | if item 18 | selected.include? item.to_s 19 | else 20 | selected.any? 21 | end 22 | end 23 | 24 | def select_none 25 | self.selected = [] 26 | end 27 | 28 | def select_all 29 | self.selected = items.pluck(:id).map(&:to_s) 30 | end 31 | 32 | def selected_items 33 | items.where(id: selected) 34 | end 35 | 36 | def items 37 | @scope 38 | end 39 | 40 | def self.action_param_key 41 | :action 42 | end 43 | 44 | def self.permit(params) 45 | params.fetch(model_name.param_key, {}).permit(:action, selected: []) 46 | end 47 | 48 | def self.action(params) 49 | params.dig model_name.param_key, action_param_key 50 | end 51 | end 52 | 53 | protected 54 | 55 | def assign_selection 56 | @selection = Selection.new(scope: scope, **permitted_batch_params) 57 | end 58 | 59 | def permitted_batch_params 60 | Selection.permit params 61 | end 62 | 63 | def method_for_action(action_name) 64 | routable_batch_action? ? Selection.action(params) : super 65 | end 66 | 67 | def routable_batch_action? 68 | self.class.action_methods.include? Selection.action(params) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.1].define(version: 2023_03_20_162105) do 14 | create_table "blogs", force: :cascade do |t| 15 | t.string "title" 16 | t.integer "user_id", null: false 17 | t.datetime "created_at", null: false 18 | t.datetime "updated_at", null: false 19 | t.index ["user_id"], name: "index_blogs_on_user_id" 20 | end 21 | 22 | create_table "posts", force: :cascade do |t| 23 | t.string "title" 24 | t.text "content" 25 | t.datetime "publish_at" 26 | t.integer "user_id", null: false 27 | t.integer "blog_id", null: false 28 | t.datetime "created_at", null: false 29 | t.datetime "updated_at", null: false 30 | t.index ["blog_id"], name: "index_posts_on_blog_id" 31 | t.index ["user_id"], name: "index_posts_on_user_id" 32 | end 33 | 34 | create_table "users", force: :cascade do |t| 35 | t.string "name" 36 | t.string "email" 37 | t.datetime "created_at", null: false 38 | t.datetime "updated_at", null: false 39 | end 40 | 41 | add_foreign_key "blogs", "users" 42 | add_foreign_key "posts", "blogs" 43 | add_foreign_key "posts", "users" 44 | end 45 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /app/controllers/blogs_controller.rb: -------------------------------------------------------------------------------- 1 | class BlogsController < ApplicationController 2 | resources :blogs, from: :current_user 3 | 4 | class Form < ApplicationForm 5 | def template 6 | labeled field(:title).input 7 | 8 | submit 9 | end 10 | end 11 | 12 | class Show < ApplicationView 13 | attr_writer :blog, :current_user 14 | turbo method: :morph do 15 | stream_from @blog, @current_user, @blog.posts 16 | end 17 | 18 | def title = @blog.title 19 | def subtitle 20 | plain "Owned and operated by " 21 | show(@blog.user, :name) 22 | end 23 | 24 | def template(&) 25 | render TableComponent.new(items: @blog.posts) do |table| 26 | table.column("Title") { show(_1, :title) } 27 | table.column("Author") { show(_1.user, :name) } 28 | table.column("Status") { "Not Published" } 29 | table.column("Publish Date") { _1.publish_at&.to_formatted_s(:long) } 30 | end 31 | nav do 32 | create(@blog.posts, role: "button") 33 | show(blog_batch_posts_path(@blog)){ "Select Posts" } 34 | edit(@blog) 35 | delete(@blog) 36 | end 37 | end 38 | end 39 | 40 | class Edit < Show 41 | def template 42 | render Form.new(@blog) 43 | end 44 | end 45 | 46 | class Index < ApplicationView 47 | attr_accessor :blogs, :current_user 48 | 49 | turbo method: :morph do 50 | stream_from @current_user, @blogs 51 | end 52 | 53 | def title = "Blogs" 54 | def subtitle = "Looks like #{helpers.pluralize @blogs.count, "blog"} have been created" 55 | 56 | def template 57 | render TableComponent.new(items: @blogs) do |table| 58 | table.column("Title") { show(_1, :title) } 59 | table.column("Owner") { show(_1.user, :name) } 60 | end 61 | create(@current_user.blogs, role: "button") 62 | end 63 | end 64 | 65 | def created_url 66 | @blog 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | /* Purple Light scheme (Default) */ 18 | /* Can be forced with data-theme="light" */ 19 | [data-theme="light"], 20 | :root:not([data-theme="dark"]) { 21 | --primary: #8e24aa; 22 | --primary-hover: #7b1fa2; 23 | --primary-focus: rgba(142, 36, 170, 0.125); 24 | --primary-inverse: #FFF; 25 | } 26 | 27 | /* Purple Dark scheme (Auto) */ 28 | /* Automatically enabled if user has Dark mode enabled */ 29 | @media only screen and (prefers-color-scheme: dark) { 30 | :root:not([data-theme]) { 31 | --primary: #8e24aa; 32 | --primary-hover: #9c27b0; 33 | --primary-focus: rgba(142, 36, 170, 0.25); 34 | --primary-inverse: #FFF; 35 | } 36 | } 37 | 38 | /* Purple Dark scheme (Forced) */ 39 | /* Enabled if forced with data-theme="dark" */ 40 | [data-theme="dark"] { 41 | --primary: #8e24aa; 42 | --primary-hover: #9c27b0; 43 | --primary-focus: rgba(142, 36, 170, 0.25); 44 | --primary-inverse: #FFF; 45 | } 46 | 47 | /* Purple (Common styles) */ 48 | :root { 49 | --form-element-active-border-color: var(--primary); 50 | --form-element-focus-color: var(--primary-focus); 51 | --switch-color: var(--primary-inverse); 52 | --switch-checked-background-color: var(--primary); 53 | } 54 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/controllers/concerns/resource.rb: -------------------------------------------------------------------------------- 1 | module Resource 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | class_attribute :resource_name 6 | before_action :assign_resource_instance_variables, if: :is_resource? 7 | end 8 | 9 | class_methods do 10 | def resources(name, ...) 11 | self.resource_name = name.to_s.singularize 12 | assign(name, ...) 13 | end 14 | end 15 | 16 | def create 17 | unrendered_phlex_action(:new).forms.first.assign params.require(resource_name).permit! 18 | 19 | if @resource.save! 20 | redirect_to @resource 21 | else 22 | # Maybe I should just render it with a string? That way 23 | # if there's an error I can just show the darn thing; if there's 24 | # not an error, then I can simply redirect. I'd somehow have to 25 | # deferr rendering. 26 | render phlex_action(:new), status: :unprocessable_entity 27 | end 28 | end 29 | 30 | def update 31 | unrendered_phlex_action(:edit).forms.first.assign params.require(resource_name).permit! 32 | 33 | if @resource.save 34 | redirect_to @resource 35 | else 36 | # Maybe I should just render it with a string? That way 37 | # if there's an error I can just show the darn thing; if there's 38 | # not an error, then I can simply redirect. I'd somehow have to 39 | # deferr rendering. 40 | render phlex_action(:edit), status: :unprocessable_entity 41 | end 42 | end 43 | 44 | def destroy 45 | @resource.destroy 46 | redirect_to destroyed_url 47 | end 48 | 49 | protected 50 | 51 | def destroyed_url 52 | url_for(action: :index) 53 | end 54 | 55 | def created_url 56 | @resource 57 | end 58 | alias :updated_url :created_url 59 | 60 | private 61 | 62 | def unrendered_phlex_action(action) 63 | phlex_action(action).tap do |view| 64 | view.call(Phlex::BlackHole, view_context: view_context) 65 | end 66 | end 67 | 68 | def resource_forms(action) 69 | unrendered_phlex_action(action).forms 70 | end 71 | 72 | def assign_resource_instance_variables 73 | @resource = instance_variable_get("@#{resource_name.singularize}") 74 | @resources = instance_variable_get("@#{resource_name.pluralize}") 75 | end 76 | 77 | def is_resource? 78 | self.class.resource_name.present? 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile 4 | ARG RUBY_VERSION=3.2.1 5 | FROM ruby:$RUBY_VERSION-slim as base 6 | 7 | LABEL fly_launch_runtime="rails" 8 | 9 | # Rails app lives here 10 | WORKDIR /rails 11 | 12 | # Set production environment 13 | ENV RAILS_ENV="production" \ 14 | BUNDLE_WITHOUT="development:test" \ 15 | BUNDLE_DEPLOYMENT="1" 16 | 17 | # Update gems and bundler 18 | RUN gem update --system --no-document && \ 19 | gem install -N bundler 20 | 21 | 22 | # Throw-away build stage to reduce size of final image 23 | FROM base as build 24 | 25 | # Install packages needed to build gems 26 | RUN apt-get update -qq && \ 27 | apt-get install --no-install-recommends -y build-essential git pkg-config 28 | 29 | # Install application gems 30 | COPY --link Gemfile Gemfile.lock ./ 31 | RUN bundle install && \ 32 | bundle exec bootsnap precompile --gemfile && \ 33 | rm -rf ~/.bundle/ $BUNDLE_PATH/ruby/*/cache $BUNDLE_PATH/ruby/*/bundler/gems/*/.git 34 | 35 | # Copy application code 36 | COPY --link . . 37 | 38 | # Precompile bootsnap code for faster boot times 39 | RUN bundle exec bootsnap precompile app/ lib/ 40 | 41 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY 42 | RUN SECRET_KEY_BASE=DUMMY ./bin/rails assets:precompile 43 | 44 | # Final stage for app image 45 | FROM base 46 | 47 | # Install packages needed for deployment 48 | RUN apt-get update -qq && \ 49 | apt-get install --no-install-recommends -y libsqlite3-0 && \ 50 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 51 | 52 | # Run and own the application files as a non-root user for security 53 | RUN useradd rails --home /rails --shell /bin/bash 54 | USER rails:rails 55 | 56 | # Copy built artifacts: gems, application 57 | COPY --from=build /usr/local/bundle /usr/local/bundle 58 | COPY --from=build --chown=rails:rails /rails /rails 59 | 60 | # Sqlite data will be stored in data. 61 | VOLUME /data 62 | 63 | # Deployment options 64 | ENV RAILS_LOG_TO_STDOUT="1" \ 65 | RAILS_SERVE_STATIC_FILES="true" \ 66 | LITESTACK_DATA_PATH="/data" 67 | 68 | # Entrypoint prepares the database. 69 | ENTRYPOINT ["/rails/bin/docker-entrypoint"] 70 | 71 | # Start the server by default, this can be overwritten at runtime 72 | EXPOSE 3000 73 | CMD ["./bin/rails", "server"] 74 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /app/views/superview/turbo.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | module Turbo 3 | # Renders the metatags for setting up Turbo Drive. 4 | class MetaTags < ApplicationComponent 5 | attr_accessor \ 6 | :method, 7 | :scroll, 8 | :exempts_page_from_cache, 9 | :exempts_page_from_preview, 10 | :page_requires_reload 11 | 12 | METHOD = :replace 13 | SCROLL = :reset 14 | 15 | def initialize(method: METHOD, scroll: SCROLL, exempts_page_from_preview: nil, exempts_page_from_cache: nil, page_requires_reload: nil) 16 | refreshes_with method: method, scroll: scroll 17 | @exempts_page_from_cache = exempts_page_from_cache 18 | @exempts_page_from_preview = exempts_page_from_preview 19 | @page_requires_reload = page_requires_reload 20 | end 21 | 22 | def template 23 | meta(name: "turbo-refresh-method", content: @method) 24 | meta(name: "turbo-refresh-scroll", content: @scroll) 25 | meta(name: "turbo-cache-control", content: "no-cache") if @exempts_page_from_cache 26 | meta(name: "turbo-cache-control", content: "no-preview") if @exempts_page_from_preview 27 | meta(name: "turbo-visit-control", content: "reload") if @page_requires_reload 28 | end 29 | 30 | def refreshes_with(method: METHOD, scroll: SCROLL) 31 | self.method = method 32 | self.scroll = scroll 33 | end 34 | 35 | def method=(value) 36 | raise ArgumentError, "Invalid refresh option '#{value}'" unless value.in?(%i[ replace morph ]) 37 | @method = value 38 | end 39 | 40 | def scroll=(value) 41 | raise ArgumentError, "Invalid scroll option '#{value}'" unless value.in?(%i[ reset preserve ]) 42 | @scroll = value 43 | end 44 | end 45 | 46 | module Helpers 47 | extend ActiveSupport::Concern 48 | 49 | included do 50 | register_element :turbo_cable_stream_source 51 | end 52 | 53 | class_methods do 54 | def turbo(*args, **kwargs, &block) 55 | @turbo_meta_tags = MetaTags.new(*args, **kwargs) 56 | define_method(:turbo, &block) if block 57 | end 58 | 59 | def turbo_meta_tags 60 | @turbo_meta_tags ||= MetaTags.new 61 | end 62 | end 63 | 64 | def turbo_stream_from(*streamables, **attributes) 65 | attributes[:channel] = attributes[:channel]&.to_s || "Turbo::StreamsChannel" 66 | attributes[:"signed-stream-name"] = ::Turbo::StreamsChannel.signed_stream_name(streamables) 67 | turbo_cable_stream_source **attributes, class: "hidden", style: "display: none;" 68 | end 69 | 70 | def stream_from(*streamables) 71 | streamables.each do |streamable| 72 | case streamable 73 | in association: ActiveRecord::Relation 74 | association.each { turbo_stream_from streamable } 75 | else 76 | turbo_stream_from streamable 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby "3.2.1" 5 | 6 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 7 | gem "rails", "~> 7.1" 8 | 9 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] 10 | gem "sprockets-rails" 11 | 12 | # Use sqlite3 as the database for Active Record 13 | gem "sqlite3", "~> 1.4" 14 | 15 | # Use the Puma web server [https://github.com/puma/puma] 16 | gem "puma", "~> 6.0" 17 | 18 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] 19 | gem "importmap-rails" 20 | 21 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 22 | gem "turbo-rails", "~> 2.0.0.pre.beta" 23 | 24 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 25 | gem "stimulus-rails" 26 | 27 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] 28 | gem "jbuilder" 29 | 30 | # Use Redis adapter to run Action Cable in production 31 | gem "redis", "~> 4.0" 32 | 33 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] 34 | # gem "kredis" 35 | 36 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 37 | # gem "bcrypt", "~> 3.1.7" 38 | 39 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 40 | gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] 41 | 42 | # Reduces boot times through caching; required in config/boot.rb 43 | gem "bootsnap", require: false 44 | 45 | # Use Sass to process CSS 46 | # gem "sassc-rails" 47 | 48 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 49 | # gem "image_processing", "~> 1.2" 50 | 51 | group :development, :test do 52 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 53 | gem "debug", platforms: %i[ mri mingw x64_mingw ] 54 | end 55 | 56 | group :development do 57 | # Use console on exceptions pages [https://github.com/rails/web-console] 58 | gem "web-console" 59 | 60 | # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] 61 | # gem "rack-mini-profiler" 62 | 63 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring] 64 | # gem "spring" 65 | end 66 | 67 | group :test do 68 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 69 | gem "capybara" 70 | gem "selenium-webdriver" 71 | gem "webdrivers" 72 | end 73 | 74 | gem "phlex-rails" 75 | gem "oxidizer", "~> 0.1.0", github: "rocketshipio/oxidizer" 76 | 77 | # gem "oxidizer", path: "~/Projects/rocketshipio/oxidizer" 78 | gem "dockerfile-rails", ">= 1.2", :group => :development 79 | 80 | gem "inputomatic", "~> 0.1.1" 81 | 82 | gem "rspec-rails", "~> 6.0" 83 | 84 | gem "litestack"#, github: "oldmoe/litestack" 85 | gem "superview", github: "rubymonolith/superview", branch: "main" 86 | gem "superform", github: "rubymonolith/superform", branch: "main" 87 | 88 | gem "rouge", "~> 4.1" 89 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | require 'spec_helper' 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require_relative '../config/environment' 5 | # Prevent database truncation if the environment is production 6 | abort("The Rails environment is running in production mode!") if Rails.env.production? 7 | require 'rspec/rails' 8 | # Add additional requires below this line. Rails is not loaded until this point! 9 | 10 | # Requires supporting ruby files with custom matchers and macros, etc, in 11 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 12 | # run as spec files by default. This means that files in spec/support that end 13 | # in _spec.rb will both be required and run as specs, causing the specs to be 14 | # run twice. It is recommended that you do not name files matching this glob to 15 | # end with _spec.rb. You can configure this pattern with the --pattern 16 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 17 | # 18 | # The following line is provided for convenience purposes. It has the downside 19 | # of increasing the boot-up time by auto-requiring all files in the support 20 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 21 | # require only the support files necessary. 22 | # 23 | # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } 24 | 25 | # Checks for pending migrations and applies them before tests are run. 26 | # If you are not using ActiveRecord, you can remove these lines. 27 | begin 28 | ActiveRecord::Migration.maintain_test_schema! 29 | rescue ActiveRecord::PendingMigrationError => e 30 | abort e.to_s.strip 31 | end 32 | RSpec.configure do |config| 33 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 34 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 35 | 36 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 37 | # examples within a transaction, remove the following line or assign false 38 | # instead of true. 39 | config.use_transactional_fixtures = true 40 | 41 | # You can uncomment this line to turn off ActiveRecord support entirely. 42 | # config.use_active_record = false 43 | 44 | # RSpec Rails can automatically mix in different behaviours to your tests 45 | # based on their file location, for example enabling you to call `get` and 46 | # `post` in specs under `spec/controllers`. 47 | # 48 | # You can disable this behaviour by removing the line below, and instead 49 | # explicitly tag your specs with their type, e.g.: 50 | # 51 | # RSpec.describe UsersController, type: :controller do 52 | # # ... 53 | # end 54 | # 55 | # The different available types are documented in the features, such as in 56 | # https://rspec.info/features/6-0/rspec-rails 57 | config.infer_spec_type_from_file_location! 58 | 59 | # Filter lines from Rails gems in backtraces. 60 | config.filter_rails_from_backtrace! 61 | # arbitrary gems may also be filtered via: 62 | # config.filter_gems_from_backtrace("gem name") 63 | end 64 | -------------------------------------------------------------------------------- /app/controllers/blogs/batch/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class Blogs::Batch::PostsController < ApplicationController 2 | assign :posts, through: :blogs, from: :current_user 3 | 4 | # TODO: How can I compose `assign :posts, ... with `resources`? 5 | include Batchable 6 | def scope 7 | @blog.posts 8 | end 9 | # / TODO: How can I compose `assign :posts, ... with `resources`? 10 | 11 | class Index < ApplicationView 12 | attr_accessor :posts, :blog, :current_user, :selection 13 | 14 | def title = "#{@blog.title} Posts" 15 | def subtitle 16 | plain "Select posts from " 17 | show(@blog, :title) 18 | end 19 | 20 | turbo method: :morph, scroll: :preserve do 21 | stream_from @blog, @current_user, @posts 22 | end 23 | 24 | def template(&) 25 | render ApplicationForm.new(@selection, action: url_for) do |form| 26 | render TableComponent.new(items: @selection.items) do |table| 27 | table.column do |column| 28 | column.item do |item| 29 | render form.field(:selected).collection.field.input(type: :checkbox, checked: @selection.selected?(item.id), value: item.id) 30 | end 31 | end 32 | table.column("Title") { show(_1, :title) } 33 | table.column("Author") { show(_1.user, :name) } 34 | table.column("Status") { _1.status } 35 | table.column("Publish Date") { _1.publish_at&.to_formatted_s(:long) } 36 | end 37 | 38 | nav do 39 | ul do 40 | li do 41 | render form.field(:action).button(value: "delete") 42 | end 43 | li do 44 | render form.field(:action).button(value: "publish") 45 | end 46 | li do 47 | render form.field(:action).button(value: "unpublish") 48 | end 49 | li do 50 | if @selection.selected? 51 | render form.field(:action).button(value: "select_none") 52 | else 53 | render form.field(:action).button(value: "select_all") 54 | end 55 | end 56 | li do 57 | show(@blog) { "Back to Blog" } 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | def delete 66 | @selection.selected_items.delete_all 67 | render phlex_action(:index), status: :created 68 | end 69 | 70 | def publish 71 | @selection.selected_items.update_all(publish_at: Time.current) 72 | render phlex_action(:index), status: :created 73 | end 74 | 75 | def unpublish 76 | @selection.selected_items.update_all(publish_at: nil) 77 | render phlex_action(:index), status: :created 78 | end 79 | 80 | def select_none 81 | @selection.select_none 82 | render phlex_action(:index), status: :created 83 | end 84 | 85 | def select_all 86 | @selection.select_all 87 | render phlex_action(:index), status: :created 88 | end 89 | 90 | private 91 | 92 | after_action :broadcasts_refreshes, if: :routable_batch_action? 93 | def broadcasts_refreshes 94 | @selection.selected_items.each(&:touch) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /app/controllers/concerns/assignable.rb: -------------------------------------------------------------------------------- 1 | module Assignable 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | class_attribute :model, :parent_model, :context_method_name 6 | 7 | before_action :assign_parent_collection, if: :has_parent_model? 8 | before_action :assign_parent_member, if: :has_parent_model? 9 | before_action :assign_collection 10 | before_action :assign_member 11 | end 12 | 13 | def assign_collection 14 | instance_variable_set "@#{model.model_name.plural}", model_scope 15 | end 16 | 17 | def assign_parent_collection 18 | instance_variable_set "@#{parent_model.model_name.plural}", parent_model_scope 19 | end 20 | 21 | def model_scope 22 | if has_parent_model? 23 | parent_model_instance.association(model.model_name.collection) 24 | elsif has_assignable_context? 25 | assignable_context.association(model.model_name.collection).scope 26 | else 27 | model.scope_for_association 28 | end 29 | end 30 | 31 | def parent_model_scope 32 | if has_assignable_context? 33 | assignable_context.association(parent_model.model_name.collection) 34 | else 35 | parent_model.scope_for_association 36 | end 37 | end 38 | 39 | def parent_model_instance 40 | parent_model_scope.find(params.fetch(parent_model_param_key)) 41 | end 42 | 43 | def assign_parent_member 44 | instance_variable_set "@#{parent_model.model_name.singular}", parent_model_instance 45 | end 46 | 47 | def has_parent_model? 48 | parent_model.present? 49 | end 50 | 51 | def assign_member 52 | instance_variable_set "@#{model.model_name.singular}", model_instance 53 | end 54 | 55 | def model_instance 56 | if member? 57 | model_scope.find params.fetch(model_param_key) 58 | else 59 | model_scope.build.tap do |post| 60 | # # Blog is a reflection of User 61 | # # Get the name of the `user` association. 62 | # parent_from_association = parent_model_scope.reflection.inverse_of 63 | 64 | # if model.reflect_on_association(parent_from_association.name) 65 | # similar_association = model.association parent_from_association.name 66 | # # Now let's see if that association exists on the current_model .. 67 | # # 68 | # # This isn't setting the foreign key ... errrggggg. 69 | # raise 'hell' 70 | 71 | # # post.association(association_name).target = parent_model_scope.owner 72 | # end 73 | end 74 | end 75 | end 76 | 77 | def member? 78 | params.key? model_param_key 79 | end 80 | 81 | def model_param_key 82 | :id 83 | end 84 | 85 | def parent_model_param_key 86 | "#{parent_model.model_name.singular}_id".to_sym 87 | end 88 | 89 | def assignable_context 90 | self.send self.class.context_method_name 91 | end 92 | 93 | def has_assignable_context? 94 | if self.class.context_method_name 95 | self.respond_to? self.class.context_method_name 96 | end 97 | end 98 | 99 | class_methods do 100 | def assign(scope, through: nil, from: nil) 101 | self.model = Assignable.find_scope scope 102 | self.parent_model = Assignable.find_scope through 103 | self.context_method_name = from 104 | end 105 | end 106 | 107 | def self.find_scope(name) 108 | name.to_s.singularize.camelize.constantize if name 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :litecache, { path: './path/to/your/cache/file' } 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :litejob 63 | # config.active_job.queue_name_prefix = "resourcerer_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # This allows you to limit a spec run to individual examples or groups 51 | # you care about by tagging them with `:focus` metadata. When nothing 52 | # is tagged with `:focus`, all examples get run. RSpec also provides 53 | # aliases for `it`, `describe`, and `context` that include `:focus` 54 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 55 | config.filter_run_when_matching :focus 56 | 57 | # Allows RSpec to persist some state between runs in order to support 58 | # the `--only-failures` and `--next-failure` CLI options. We recommend 59 | # you configure your source control system to ignore this file. 60 | config.example_status_persistence_file_path = "spec/examples.txt" 61 | 62 | # Limits the available syntax to the non-monkey patched syntax that is 63 | # recommended. For more details, see: 64 | # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ 65 | config.disable_monkey_patching! 66 | 67 | # Many RSpec users commonly either run the entire suite or an individual 68 | # file, and it's useful to allow more verbose output when running an 69 | # individual spec file. 70 | if config.files_to_run.one? 71 | # Use the documentation formatter for detailed output, 72 | # unless a formatter has already been configured 73 | # (e.g. via a command-line flag). 74 | config.default_formatter = "doc" 75 | end 76 | 77 | # Print the 10 slowest examples and example groups at the 78 | # end of the spec run, to help surface which specs are running 79 | # particularly slow. 80 | config.profile_examples = 10 81 | 82 | # Run specs in random order to surface order dependencies. If you find an 83 | # order dependency and want to debug it, you can fix the order by providing 84 | # the seed, which is printed after each run. 85 | # --seed 1234 86 | config.order = :random 87 | 88 | # Seed global randomization in this process using the `--seed` CLI option. 89 | # Setting this allows you to use `--seed` to deterministically reproduce 90 | # test failures related to randomization by passing the same `--seed` value 91 | # as the one that triggered the failure. 92 | Kernel.srand config.seed 93 | =end 94 | end 95 | -------------------------------------------------------------------------------- /spec/views/phlex/superform_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe Superform::Namespace do 4 | User = Data.define(:name, :email, :nicknames, :addresses) 5 | Address = Data.define(:id, :street, :city) 6 | 7 | let(:user) do 8 | User.new( 9 | name: "Brad", 10 | email: "brad@example.com", 11 | nicknames: ["Dude", "The Dude"], 12 | addresses: [ 13 | Address.new(id: 100, street: "Main St", city: "Small Town"), 14 | Address.new(id: 200, street: "Big Blvd", city: "Metropolis") 15 | ] 16 | ) 17 | end 18 | 19 | let(:form) do 20 | Superform::Namespace.new(:user, object: user) 21 | end 22 | 23 | describe "root" do 24 | subject { form } 25 | it "has key" do 26 | expect(form.key).to eql(:user) 27 | end 28 | context "DOM" do 29 | let(:dom) { Superform::DOM.new(form) } 30 | it "has name" do 31 | expect(dom.name).to eql("user") 32 | end 33 | it "has id" do 34 | expect(dom.id).to eql("user") 35 | end 36 | it "has title" do 37 | expect(dom.title).to eql("User") 38 | end 39 | end 40 | end 41 | 42 | describe "child" do 43 | let(:field) { form.field(:name) } 44 | subject { field } 45 | it "returns value from parent" do 46 | expect(subject.value).to eql "Brad" 47 | end 48 | context "DOM" do 49 | let(:dom) { Superform::DOM.new(field) } 50 | 51 | it "has name" do 52 | expect(dom.name).to eql("user[name]") 53 | end 54 | it "has id" do 55 | expect(dom.id).to eql("user_name") 56 | end 57 | it "has key" do 58 | expect(dom.key).to eql("name") 59 | end 60 | it "has title" do 61 | expect(dom.title).to eql("Name") 62 | end 63 | end 64 | end 65 | 66 | describe "#field" do 67 | let(:field) { form.namespace(:one).namespace(:two).namespace(:three).field(:four, value: 5) } 68 | subject{ field } 69 | it "returns value" do 70 | expect(subject.value).to eql 5 71 | end 72 | context "DOM" do 73 | let(:dom) { Superform::DOM.new(field) } 74 | 75 | it "has name" do 76 | expect(dom.name).to eql("user[one][two][three][four]") 77 | end 78 | it "has id" do 79 | expect(dom.id).to eql("user_one_two_three_four") 80 | end 81 | it "has key" do 82 | expect(dom.key).to eql("four") 83 | end 84 | it "has title" do 85 | expect(dom.title).to eql("Four") 86 | end 87 | end 88 | end 89 | 90 | describe "#values" do 91 | context "array of values" do 92 | let(:field) { form.collection(:nicknames).first } 93 | subject { field } 94 | it "returns value" do 95 | expect(subject.value).to eql "Dude" 96 | end 97 | it "returns key" do 98 | expect(subject.key).to eql 0 99 | end 100 | context "DOM" do 101 | let(:dom) { Superform::DOM.new(field) } 102 | 103 | it "has name" do 104 | expect(dom.name).to eql("user[nicknames][]") 105 | end 106 | it "has id" do 107 | expect(dom.id).to eql("user_nicknames_0") 108 | end 109 | it "has key" do 110 | expect(dom.key).to eql("0") 111 | end 112 | it "has title" do 113 | expect(dom.title).to eql("0") 114 | end 115 | end 116 | end 117 | 118 | context "array of objects" do 119 | let(:field) { form.collection(:addresses).first.field(:street) } 120 | subject { field } 121 | it "returns value" do 122 | expect(subject.value).to eql("Main St") 123 | end 124 | it "returns key" do 125 | expect(subject.key).to eql(:street) 126 | end 127 | context "DOM" do 128 | let(:dom) { Superform::DOM.new(field) } 129 | 130 | it "has name" do 131 | expect(dom.name).to eql("user[addresses][][street]") 132 | end 133 | it "has id" do 134 | expect(dom.id).to eql("user_addresses_0_street") 135 | end 136 | it "has key" do 137 | expect(dom.key).to eql("street") 138 | end 139 | it "has title" do 140 | expect(dom.title).to eql("Street") 141 | end 142 | end 143 | end 144 | end 145 | 146 | describe "mapping" do 147 | subject { form.to_h } 148 | 149 | before do 150 | form.namespace(:name) do |name| 151 | name.field(:first, value: "Brad") 152 | name.field(:last) 153 | end 154 | form.field(:email) 155 | form.collection(:nicknames) 156 | form.collection(:addresses) do |address| 157 | address.field(:id, permit: false) 158 | address.field(:street) 159 | end 160 | form.collection(:modulo, object: 4.times) do |modulo| 161 | if (modulo.value % 2 == 0) 162 | modulo.field(:fizz, value: modulo.value) 163 | else 164 | modulo.field(:buzz) do |buzz| 165 | buzz.field(:saw, value: modulo.value) 166 | end 167 | end 168 | end 169 | end 170 | 171 | describe "#to_h" do 172 | it do 173 | is_expected.to eql( 174 | name: { first: "Brad", last: "d" }, 175 | nicknames: ["Dude", "The Dude"], 176 | email: "brad@example.com", 177 | addresses: [ 178 | { id: 100, street: "Main St" }, 179 | { id: 200, street: "Big Blvd" } 180 | ], 181 | modulo: [ 182 | { fizz: 0 }, 183 | { buzz: { saw: 1 }}, 184 | { fizz: 2 }, 185 | { buzz: { saw: 3 }} 186 | ] 187 | ) 188 | end 189 | end 190 | 191 | describe "#assign" do 192 | let(:params) do 193 | { 194 | email: "bard@example.com", 195 | malicious_garbage: "haha! You will never win my pretty!", 196 | nicknames: nil, 197 | addresses: [ 198 | { 199 | id: 999, 200 | street: "Lame Street", 201 | extra_garbage: "Super garbage" 202 | }, 203 | { 204 | id: 888, 205 | street: "Bigsby Lane", 206 | malicious_garbage: "I will steal your address!" 207 | }, 208 | { 209 | id: 777, 210 | street: "Amazing Avenue", 211 | lots_of_trash: "I will steal your address!" 212 | } 213 | ], 214 | modulo: [ 215 | { fizz: 200, malicious_garbage: "I will foil your plans!" }, 216 | { malicious_garbage: "The world will be mine!" } 217 | ] 218 | } 219 | end 220 | before { form.assign params } 221 | 222 | it "does not include fields not in list" do 223 | expect(subject.keys).to_not include :malicious_garbage 224 | end 225 | 226 | it "includes fields in list" do 227 | expect(form.to_h).to eql( 228 | name: { first: "Brad", last: "d" }, 229 | nicknames: nil, 230 | email: "bard@example.com", 231 | addresses: [ 232 | { id: 100, street: "Lame Street" }, 233 | { id: 200, street: "Bigsby Lane" }, 234 | { id: 777, street: "Amazing Avenue" } 235 | ], 236 | modulo: [ 237 | { fizz: 200 }, 238 | { buzz: { saw: 1 }}, 239 | { fizz: 2 }, 240 | { buzz: { saw: 3 }} 241 | ] 242 | ) 243 | end 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Demonstrates the use of a Phlex app that maps closely to the database tables of an application. 4 | 5 | * [Demo](https://oxidizer-demo.fly.dev) - This repo deployed to [Fly.io](https://fly.io/docs/rails/) 6 | * [Component-driven Development on Rails with Phlex](https://fly.io/ruby-dispatch/component-driven-development-on-rails-with-phlex/) - Article that covers some of the more notable points of this demo repo. 7 | * [Phlex](https://www.phlex.fun) - Rubygem that generates HTML from Ruby classes. 8 | 9 | ## Embedded Phlex views in controllers 10 | 11 | Phlex classes are used to render HTML views. Now Erb, partials, or templates are used. This demonstrates the feasiblity of component-driven application development with Rails. 12 | 13 | Here's an example of a controller with embedded Phlex classes: 14 | 15 | ```ruby 16 | class Users::BlogsController < ApplicationController 17 | resources :blogs, from: :current_user 18 | 19 | class New < ApplicationView 20 | attr_writer :blog 21 | 22 | def template 23 | h1 { "Create a new blog" } 24 | render BlogsController::Form.new(@blog) 25 | end 26 | end 27 | 28 | class Index < ApplicationView 29 | attr_writer :blogs, :current_user 30 | 31 | def template(&) 32 | h1 { "#{@current_user.name}'s Blogs" } 33 | section do 34 | ul { 35 | @blogs.each { |blog| 36 | li { show(blog, :title) } 37 | } 38 | } 39 | create(@current_user.blogs, role: "button") 40 | end 41 | end 42 | end 43 | end 44 | ``` 45 | 46 | ## Shallow RESTful applications 47 | 48 | This project picks up where Rails left off with Shallow RESTful routes. [Boring Rails](https://boringrails.com/tips/rails-scoped-controllers-sharing-code) does a decent job covering it, but as you'll see there's a lot to be desired. 49 | 50 | But first, it's important to understand the use of modules in controllers to manage the context in which things are created. 51 | 52 | For example, when creating a post for a blog, the URL would be `/blogs/100/posts/new`, which maps to the controller at `Blogs::PostsController#new`, which eventually creates the object via `User.find(session[:user_id]).blogs.find(params[:blog_id]).build(params.require(:post).permit(:title, :post)).create!` in ActiveRecord. 53 | 54 | It's really annoying typing that out every single time, so let's see how we can do better. 55 | 56 | ### Route helpers 57 | 58 | Routes look like this: 59 | 60 | ```ruby 61 | Rails.application.routes.draw do 62 | resources :blogs do 63 | nest :posts 64 | end 65 | end 66 | ``` 67 | 68 | 69 | #### The old way 70 | 71 | If you did it the old way, you'd end up littering your routes file with `scope module: ...` calls, which makes the situation less readable. 72 | 73 | ```ruby 74 | Rails.application.routes.draw do 75 | resources :blogs do 76 | scope module: :blogs do 77 | resources :posts 78 | end 79 | end 80 | end 81 | ``` 82 | 83 | ### Link helpers 84 | 85 | Link helpers are actually RESTful. Want to show a blog and have the link text be the title of the blog? 86 | 87 | ```ruby 88 | show(@blog, :title) 89 | ``` 90 | Need to edit that blog? 91 | 92 | ```ruby 93 | edit(@blog) 94 | ``` 95 | 96 | The text of the link defaults to "Edit Blog", but you can make it whatever you want by passing in a block: 97 | 98 | ```ruby 99 | edit(@blog) { "Edit the #{@blog.title} Blog" } 100 | ``` 101 | 102 | Same for deleting the blog. 103 | 104 | ```ruby 105 | delete(@blog) 106 | ``` 107 | 108 | Where things get interesting is creating stuff. If you pass a relationship into the blog helper, it will be able to infer its parent. For example, this 109 | 110 | ```ruby 111 | create(@blog.posts) 112 | ``` 113 | 114 | Will understand that it should link to the `Blog::PostsController#new` action because it can reflect on the relationship. 115 | 116 | Similarly if you pass in an unpersisted model. 117 | 118 | ```ruby 119 | create(@blog) 120 | ``` 121 | 122 | It will figure it out. 123 | 124 | #### The old way 125 | 126 | Rails started off with reasonable URL helpers. If you wanted to delete a resource, you could do something like this: 127 | 128 | ```erb 129 | <%=link_to "Delete Blog", @blog, method: :delete %> 130 | ``` 131 | 132 | But then Turbo came along and for some reason things got more complicated because "consistency", so we ended up with this: 133 | 134 | ```erb 135 | <%= link_to "Delete Blog", @blog, data: {"turbo-method": :delete } %> 136 | ``` 137 | 138 | Gah! Compare that to the new way: 139 | 140 | ```ruby 141 | delete(@blog) 142 | ``` 143 | 144 | Creation is where things get more interesting, you're probably use to this: 145 | 146 | ```ruby 147 | <%= link_to "Create Blog Post", new_blog_post_path(@blog) %> 148 | ``` 149 | 150 | The new way requires much less typing: 151 | 152 | ```ruby 153 | create(@blog.posts) { "Create Blog Post" } 154 | ``` 155 | 156 | ### Controller helpers 157 | 158 | So much time is spent in Rails controllers writing code that loads data from params passed into the controller into ActiveRecord models. 159 | 160 | Oxidizer reduces that down to one line: 161 | 162 | ```ruby 163 | class Blogs::PostsController < ApplicationController 164 | assign :posts, through: :blogs, from: :current_user 165 | end 166 | ``` 167 | 168 | From your views you'd have access to `@posts`, `@post`, `@blog`, `@blogs`. 169 | 170 | But wait, there's more! If you change `assign` to `resources`, you get that plus `@resource`, `@resources`, `@parent_resource`, and `@parent_resources` assigned so you can implement components against those variables that resemble scaffolding. 171 | 172 | ```ruby 173 | class Blogs::PostsController < ApplicationController 174 | resources :posts, through: :blogs, from: :current_user 175 | end 176 | ``` 177 | 178 | It also defines reasonable default behaviors for creating, updating, and destroying resources. 179 | 180 | #### The old way 181 | 182 | To accomplish the same thing in your controller, you might have had to do something like this. 183 | 184 | ```ruby 185 | module Blogs 186 | class PostsController < ApplicationController 187 | before_action :set_blog 188 | before_action :set_post, only: %i[ show edit update destroy ] 189 | 190 | def index 191 | @posts = @blog.posts.all 192 | end 193 | 194 | # GET /posts/1 or /posts/1.json 195 | def show 196 | end 197 | 198 | # GET /posts/new 199 | def new 200 | @post = @blog.posts.build 201 | end 202 | 203 | # GET /posts/1/edit 204 | def edit 205 | end 206 | 207 | # POST /posts or /posts.json 208 | def create 209 | @post = Post.new(post_params) 210 | 211 | respond_to do |format| 212 | if @post.save 213 | format.html { redirect_to post_url(@post), notice: "Post was successfully created." } 214 | format.json { render :show, status: :created, location: @post } 215 | else 216 | format.html { render :new, status: :unprocessable_entity } 217 | format.json { render json: @post.errors, status: :unprocessable_entity } 218 | end 219 | end 220 | end 221 | 222 | # PATCH/PUT /posts/1 or /posts/1.json 223 | def update 224 | respond_to do |format| 225 | if @post.update(post_params) 226 | format.html { redirect_to post_url(@post), notice: "Post was successfully updated." } 227 | format.json { render :show, status: :ok, location: @post } 228 | else 229 | format.html { render :edit, status: :unprocessable_entity } 230 | format.json { render json: @post.errors, status: :unprocessable_entity } 231 | end 232 | end 233 | end 234 | 235 | # DELETE /posts/1 or /posts/1.json 236 | def destroy 237 | @post.destroy 238 | 239 | respond_to do |format| 240 | format.html { redirect_to posts_url, notice: "Post was successfully destroyed." } 241 | format.json { head :no_content } 242 | end 243 | end 244 | 245 | private 246 | # Use callbacks to share common setup or constraints between actions. 247 | def set_post 248 | @post = @blog.posts.find(params[:id]) 249 | end 250 | 251 | def set_blog 252 | @blog = Blog.find(params[:blog_id]) 253 | end 254 | 255 | # Only allow a list of trusted parameters through. 256 | def post_params 257 | params.fetch(:post, {}).permit(:title, :content) 258 | end 259 | 260 | # This is probably on the ApplicationController 261 | def current_user 262 | User.find session[:user_id] 263 | end 264 | end 265 | end 266 | ``` 267 | 268 | It's possible to clean this up, which [Boring Rails](https://boringrails.com/tips/rails-scoped-controllers-sharing-code) writes about. 269 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/rocketshipio/oxidizer.git 3 | revision: fe600852c4f47a1faf0d392476a71b0ee4fb0042 4 | specs: 5 | oxidizer (0.1.0) 6 | rails (>= 7.0.3) 7 | 8 | GIT 9 | remote: https://github.com/rubymonolith/superform.git 10 | revision: 0bf986c2a40bf3dda5b974266c6c0f9d008b28d5 11 | branch: main 12 | specs: 13 | superform (0.4.2) 14 | phlex-rails (~> 1.0) 15 | zeitwerk (~> 2.6) 16 | 17 | GIT 18 | remote: https://github.com/rubymonolith/superview.git 19 | revision: 2b15ea80b650a03de9975de2fe157bc35cfa4c27 20 | branch: main 21 | specs: 22 | superview (0.1.0) 23 | phlex-rails (~> 1.0) 24 | 25 | GEM 26 | remote: https://rubygems.org/ 27 | specs: 28 | actioncable (7.1.2) 29 | actionpack (= 7.1.2) 30 | activesupport (= 7.1.2) 31 | nio4r (~> 2.0) 32 | websocket-driver (>= 0.6.1) 33 | zeitwerk (~> 2.6) 34 | actionmailbox (7.1.2) 35 | actionpack (= 7.1.2) 36 | activejob (= 7.1.2) 37 | activerecord (= 7.1.2) 38 | activestorage (= 7.1.2) 39 | activesupport (= 7.1.2) 40 | mail (>= 2.7.1) 41 | net-imap 42 | net-pop 43 | net-smtp 44 | actionmailer (7.1.2) 45 | actionpack (= 7.1.2) 46 | actionview (= 7.1.2) 47 | activejob (= 7.1.2) 48 | activesupport (= 7.1.2) 49 | mail (~> 2.5, >= 2.5.4) 50 | net-imap 51 | net-pop 52 | net-smtp 53 | rails-dom-testing (~> 2.2) 54 | actionpack (7.1.2) 55 | actionview (= 7.1.2) 56 | activesupport (= 7.1.2) 57 | nokogiri (>= 1.8.5) 58 | racc 59 | rack (>= 2.2.4) 60 | rack-session (>= 1.0.1) 61 | rack-test (>= 0.6.3) 62 | rails-dom-testing (~> 2.2) 63 | rails-html-sanitizer (~> 1.6) 64 | actiontext (7.1.2) 65 | actionpack (= 7.1.2) 66 | activerecord (= 7.1.2) 67 | activestorage (= 7.1.2) 68 | activesupport (= 7.1.2) 69 | globalid (>= 0.6.0) 70 | nokogiri (>= 1.8.5) 71 | actionview (7.1.2) 72 | activesupport (= 7.1.2) 73 | builder (~> 3.1) 74 | erubi (~> 1.11) 75 | rails-dom-testing (~> 2.2) 76 | rails-html-sanitizer (~> 1.6) 77 | activejob (7.1.2) 78 | activesupport (= 7.1.2) 79 | globalid (>= 0.3.6) 80 | activemodel (7.1.2) 81 | activesupport (= 7.1.2) 82 | activerecord (7.1.2) 83 | activemodel (= 7.1.2) 84 | activesupport (= 7.1.2) 85 | timeout (>= 0.4.0) 86 | activestorage (7.1.2) 87 | actionpack (= 7.1.2) 88 | activejob (= 7.1.2) 89 | activerecord (= 7.1.2) 90 | activesupport (= 7.1.2) 91 | marcel (~> 1.0) 92 | activesupport (7.1.2) 93 | base64 94 | bigdecimal 95 | concurrent-ruby (~> 1.0, >= 1.0.2) 96 | connection_pool (>= 2.2.5) 97 | drb 98 | i18n (>= 1.6, < 2) 99 | minitest (>= 5.1) 100 | mutex_m 101 | tzinfo (~> 2.0) 102 | addressable (2.8.6) 103 | public_suffix (>= 2.0.2, < 6.0) 104 | base64 (0.2.0) 105 | bigdecimal (3.1.4) 106 | bindex (0.8.1) 107 | bootsnap (1.17.0) 108 | msgpack (~> 1.2) 109 | builder (3.2.4) 110 | capybara (3.39.2) 111 | addressable 112 | matrix 113 | mini_mime (>= 0.1.3) 114 | nokogiri (~> 1.8) 115 | rack (>= 1.6.0) 116 | rack-test (>= 0.6.3) 117 | regexp_parser (>= 1.5, < 3.0) 118 | xpath (~> 3.2) 119 | cgi (0.4.1) 120 | chronic (0.10.2) 121 | concurrent-ruby (1.2.2) 122 | connection_pool (2.4.1) 123 | crass (1.0.6) 124 | date (3.3.4) 125 | debug (1.9.0) 126 | irb (~> 1.10) 127 | reline (>= 0.3.8) 128 | diff-lcs (1.5.0) 129 | dockerfile-rails (1.5.14) 130 | rails (>= 3.0.0) 131 | drb (2.2.0) 132 | ruby2_keywords 133 | erb (4.0.3) 134 | cgi (>= 0.3.3) 135 | erubi (1.12.0) 136 | globalid (1.2.1) 137 | activesupport (>= 6.1) 138 | hanami-router (2.0.2) 139 | mustermann (~> 3.0) 140 | mustermann-contrib (~> 3.0) 141 | rack (~> 2.0) 142 | hansi (0.2.1) 143 | i18n (1.14.1) 144 | concurrent-ruby (~> 1.0) 145 | importmap-rails (1.2.3) 146 | actionpack (>= 6.0.0) 147 | activesupport (>= 6.0.0) 148 | railties (>= 6.0.0) 149 | inputomatic (0.1.1) 150 | chronic (~> 0.10) 151 | io-console (0.6.0) 152 | irb (1.10.1) 153 | rdoc 154 | reline (>= 0.3.8) 155 | jbuilder (2.11.5) 156 | actionview (>= 5.0.0) 157 | activesupport (>= 5.0.0) 158 | litestack (0.4.2) 159 | erubi 160 | hanami-router 161 | oj 162 | rack 163 | sqlite3 164 | tilt 165 | loofah (2.22.0) 166 | crass (~> 1.0.2) 167 | nokogiri (>= 1.12.0) 168 | mail (2.8.1) 169 | mini_mime (>= 0.1.1) 170 | net-imap 171 | net-pop 172 | net-smtp 173 | marcel (1.0.2) 174 | matrix (0.4.2) 175 | mini_mime (1.1.5) 176 | minitest (5.20.0) 177 | msgpack (1.7.2) 178 | mustermann (3.0.0) 179 | ruby2_keywords (~> 0.0.1) 180 | mustermann-contrib (3.0.0) 181 | hansi (~> 0.2.0) 182 | mustermann (= 3.0.0) 183 | mutex_m (0.2.0) 184 | net-imap (0.4.7) 185 | date 186 | net-protocol 187 | net-pop (0.1.2) 188 | net-protocol 189 | net-protocol (0.2.2) 190 | timeout 191 | net-smtp (0.4.0) 192 | net-protocol 193 | nio4r (2.7.0) 194 | nokogiri (1.15.5-arm64-darwin) 195 | racc (~> 1.4) 196 | nokogiri (1.15.5-x86_64-linux) 197 | racc (~> 1.4) 198 | oj (3.16.2) 199 | bigdecimal (~> 3.1) 200 | phlex (1.9.0) 201 | concurrent-ruby (~> 1.2) 202 | erb (>= 4) 203 | zeitwerk (~> 2.6) 204 | phlex-rails (1.1.1) 205 | phlex (~> 1.9) 206 | railties (>= 6.1, < 8) 207 | zeitwerk (~> 2.6) 208 | psych (5.1.1.1) 209 | stringio 210 | public_suffix (5.0.4) 211 | puma (6.4.0) 212 | nio4r (~> 2.0) 213 | racc (1.7.3) 214 | rack (2.2.8) 215 | rack-session (1.0.2) 216 | rack (< 3) 217 | rack-test (2.1.0) 218 | rack (>= 1.3) 219 | rackup (1.0.0) 220 | rack (< 3) 221 | webrick 222 | rails (7.1.2) 223 | actioncable (= 7.1.2) 224 | actionmailbox (= 7.1.2) 225 | actionmailer (= 7.1.2) 226 | actionpack (= 7.1.2) 227 | actiontext (= 7.1.2) 228 | actionview (= 7.1.2) 229 | activejob (= 7.1.2) 230 | activemodel (= 7.1.2) 231 | activerecord (= 7.1.2) 232 | activestorage (= 7.1.2) 233 | activesupport (= 7.1.2) 234 | bundler (>= 1.15.0) 235 | railties (= 7.1.2) 236 | rails-dom-testing (2.2.0) 237 | activesupport (>= 5.0.0) 238 | minitest 239 | nokogiri (>= 1.6) 240 | rails-html-sanitizer (1.6.0) 241 | loofah (~> 2.21) 242 | nokogiri (~> 1.14) 243 | railties (7.1.2) 244 | actionpack (= 7.1.2) 245 | activesupport (= 7.1.2) 246 | irb 247 | rackup (>= 1.0.0) 248 | rake (>= 12.2) 249 | thor (~> 1.0, >= 1.2.2) 250 | zeitwerk (~> 2.6) 251 | rake (13.1.0) 252 | rdoc (6.6.1) 253 | psych (>= 4.0.0) 254 | redis (4.8.1) 255 | regexp_parser (2.8.3) 256 | reline (0.4.1) 257 | io-console (~> 0.5) 258 | rexml (3.2.6) 259 | rouge (4.2.0) 260 | rspec-core (3.12.2) 261 | rspec-support (~> 3.12.0) 262 | rspec-expectations (3.12.3) 263 | diff-lcs (>= 1.2.0, < 2.0) 264 | rspec-support (~> 3.12.0) 265 | rspec-mocks (3.12.6) 266 | diff-lcs (>= 1.2.0, < 2.0) 267 | rspec-support (~> 3.12.0) 268 | rspec-rails (6.1.0) 269 | actionpack (>= 6.1) 270 | activesupport (>= 6.1) 271 | railties (>= 6.1) 272 | rspec-core (~> 3.12) 273 | rspec-expectations (~> 3.12) 274 | rspec-mocks (~> 3.12) 275 | rspec-support (~> 3.12) 276 | rspec-support (3.12.1) 277 | ruby2_keywords (0.0.5) 278 | rubyzip (2.3.2) 279 | selenium-webdriver (4.10.0) 280 | rexml (~> 3.2, >= 3.2.5) 281 | rubyzip (>= 1.2.2, < 3.0) 282 | websocket (~> 1.0) 283 | sprockets (4.2.1) 284 | concurrent-ruby (~> 1.0) 285 | rack (>= 2.2.4, < 4) 286 | sprockets-rails (3.4.2) 287 | actionpack (>= 5.2) 288 | activesupport (>= 5.2) 289 | sprockets (>= 3.0.0) 290 | sqlite3 (1.6.9-arm64-darwin) 291 | sqlite3 (1.6.9-x86_64-linux) 292 | stimulus-rails (1.3.0) 293 | railties (>= 6.0.0) 294 | stringio (3.1.0) 295 | thor (1.3.0) 296 | tilt (2.3.0) 297 | timeout (0.4.1) 298 | turbo-rails (2.0.0.pre.beta.1) 299 | actionpack (>= 6.0.0) 300 | activejob (>= 6.0.0) 301 | railties (>= 6.0.0) 302 | tzinfo (2.0.6) 303 | concurrent-ruby (~> 1.0) 304 | web-console (4.2.1) 305 | actionview (>= 6.0.0) 306 | activemodel (>= 6.0.0) 307 | bindex (>= 0.4.0) 308 | railties (>= 6.0.0) 309 | webdrivers (5.3.1) 310 | nokogiri (~> 1.6) 311 | rubyzip (>= 1.3.0) 312 | selenium-webdriver (~> 4.0, < 4.11) 313 | webrick (1.8.1) 314 | websocket (1.2.10) 315 | websocket-driver (0.7.6) 316 | websocket-extensions (>= 0.1.0) 317 | websocket-extensions (0.1.5) 318 | xpath (3.2.0) 319 | nokogiri (~> 1.8) 320 | zeitwerk (2.6.12) 321 | 322 | PLATFORMS 323 | arm64-darwin-22 324 | arm64-darwin-23 325 | x86_64-linux 326 | 327 | DEPENDENCIES 328 | bootsnap 329 | capybara 330 | debug 331 | dockerfile-rails (>= 1.2) 332 | importmap-rails 333 | inputomatic (~> 0.1.1) 334 | jbuilder 335 | litestack 336 | oxidizer (~> 0.1.0)! 337 | phlex-rails 338 | puma (~> 6.0) 339 | rails (~> 7.1) 340 | redis (~> 4.0) 341 | rouge (~> 4.1) 342 | rspec-rails (~> 6.0) 343 | selenium-webdriver 344 | sprockets-rails 345 | sqlite3 (~> 1.4) 346 | stimulus-rails 347 | superform! 348 | superview! 349 | turbo-rails (~> 2.0.0.pre.beta) 350 | tzinfo-data 351 | web-console 352 | webdrivers 353 | 354 | RUBY VERSION 355 | ruby 3.2.1p31 356 | 357 | BUNDLED WITH 358 | 2.4.8 359 | -------------------------------------------------------------------------------- /app/assets/stylesheets/pico.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /*! 3 | * Pico CSS v1.5.9 (https://picocss.com) 4 | * Copyright 2019-2023 - Licensed under MIT 5 | */ 6 | /** 7 | * Theme: default 8 | */ 9 | :root { 10 | --font-family: system-ui, -apple-system, "Segoe UI", "Roboto", "Ubuntu", 11 | "Cantarell", "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 12 | "Segoe UI Symbol", "Noto Color Emoji"; 13 | --line-height: 1.5; 14 | --font-weight: 400; 15 | --font-size: 16px; 16 | --border-radius: 0.25rem; 17 | --border-width: 1px; 18 | --outline-width: 3px; 19 | --spacing: 1rem; 20 | --typography-spacing-vertical: 1.5rem; 21 | --block-spacing-vertical: calc(var(--spacing) * 2); 22 | --block-spacing-horizontal: var(--spacing); 23 | --grid-spacing-vertical: 0; 24 | --grid-spacing-horizontal: var(--spacing); 25 | --form-element-spacing-vertical: 0.75rem; 26 | --form-element-spacing-horizontal: 1rem; 27 | --nav-element-spacing-vertical: 1rem; 28 | --nav-element-spacing-horizontal: 0.5rem; 29 | --nav-link-spacing-vertical: 0.5rem; 30 | --nav-link-spacing-horizontal: 0.5rem; 31 | --form-label-font-weight: var(--font-weight); 32 | --transition: 0.2s ease-in-out; 33 | --modal-overlay-backdrop-filter: blur(0.25rem); 34 | } 35 | @media (min-width: 576px) { 36 | :root { 37 | --font-size: 17px; 38 | } 39 | } 40 | @media (min-width: 768px) { 41 | :root { 42 | --font-size: 18px; 43 | } 44 | } 45 | @media (min-width: 992px) { 46 | :root { 47 | --font-size: 19px; 48 | } 49 | } 50 | @media (min-width: 1200px) { 51 | :root { 52 | --font-size: 20px; 53 | } 54 | } 55 | 56 | @media (min-width: 576px) { 57 | body > header, 58 | body > main, 59 | body > footer, 60 | section { 61 | --block-spacing-vertical: calc(var(--spacing) * 2.5); 62 | } 63 | } 64 | @media (min-width: 768px) { 65 | body > header, 66 | body > main, 67 | body > footer, 68 | section { 69 | --block-spacing-vertical: calc(var(--spacing) * 3); 70 | } 71 | } 72 | @media (min-width: 992px) { 73 | body > header, 74 | body > main, 75 | body > footer, 76 | section { 77 | --block-spacing-vertical: calc(var(--spacing) * 3.5); 78 | } 79 | } 80 | @media (min-width: 1200px) { 81 | body > header, 82 | body > main, 83 | body > footer, 84 | section { 85 | --block-spacing-vertical: calc(var(--spacing) * 4); 86 | } 87 | } 88 | 89 | @media (min-width: 576px) { 90 | article { 91 | --block-spacing-horizontal: calc(var(--spacing) * 1.25); 92 | } 93 | } 94 | @media (min-width: 768px) { 95 | article { 96 | --block-spacing-horizontal: calc(var(--spacing) * 1.5); 97 | } 98 | } 99 | @media (min-width: 992px) { 100 | article { 101 | --block-spacing-horizontal: calc(var(--spacing) * 1.75); 102 | } 103 | } 104 | @media (min-width: 1200px) { 105 | article { 106 | --block-spacing-horizontal: calc(var(--spacing) * 2); 107 | } 108 | } 109 | 110 | dialog > article { 111 | --block-spacing-vertical: calc(var(--spacing) * 2); 112 | --block-spacing-horizontal: var(--spacing); 113 | } 114 | @media (min-width: 576px) { 115 | dialog > article { 116 | --block-spacing-vertical: calc(var(--spacing) * 2.5); 117 | --block-spacing-horizontal: calc(var(--spacing) * 1.25); 118 | } 119 | } 120 | @media (min-width: 768px) { 121 | dialog > article { 122 | --block-spacing-vertical: calc(var(--spacing) * 3); 123 | --block-spacing-horizontal: calc(var(--spacing) * 1.5); 124 | } 125 | } 126 | 127 | a { 128 | --text-decoration: none; 129 | } 130 | a.secondary, a.contrast { 131 | --text-decoration: underline; 132 | } 133 | 134 | small { 135 | --font-size: 0.875em; 136 | } 137 | 138 | h1, 139 | h2, 140 | h3, 141 | h4, 142 | h5, 143 | h6 { 144 | --font-weight: 700; 145 | } 146 | 147 | h1 { 148 | --font-size: 2rem; 149 | --typography-spacing-vertical: 3rem; 150 | } 151 | 152 | h2 { 153 | --font-size: 1.75rem; 154 | --typography-spacing-vertical: 2.625rem; 155 | } 156 | 157 | h3 { 158 | --font-size: 1.5rem; 159 | --typography-spacing-vertical: 2.25rem; 160 | } 161 | 162 | h4 { 163 | --font-size: 1.25rem; 164 | --typography-spacing-vertical: 1.874rem; 165 | } 166 | 167 | h5 { 168 | --font-size: 1.125rem; 169 | --typography-spacing-vertical: 1.6875rem; 170 | } 171 | 172 | [type=checkbox], 173 | [type=radio] { 174 | --border-width: 2px; 175 | } 176 | 177 | [type=checkbox][role=switch] { 178 | --border-width: 3px; 179 | } 180 | 181 | thead th, 182 | thead td, 183 | tfoot th, 184 | tfoot td { 185 | --border-width: 3px; 186 | } 187 | 188 | :not(thead, tfoot) > * > td { 189 | --font-size: 0.875em; 190 | } 191 | 192 | pre, 193 | code, 194 | kbd, 195 | samp { 196 | --font-family: "Menlo", "Consolas", "Roboto Mono", "Ubuntu Monospace", 197 | "Noto Mono", "Oxygen Mono", "Liberation Mono", monospace, 198 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 199 | } 200 | 201 | kbd { 202 | --font-weight: bolder; 203 | } 204 | 205 | [data-theme=light], 206 | :root:not([data-theme=dark]) { 207 | --background-color: #fff; 208 | --color: hsl(205, 20%, 32%); 209 | --h1-color: hsl(205, 30%, 15%); 210 | --h2-color: #24333e; 211 | --h3-color: hsl(205, 25%, 23%); 212 | --h4-color: #374956; 213 | --h5-color: hsl(205, 20%, 32%); 214 | --h6-color: #4d606d; 215 | --muted-color: hsl(205, 10%, 50%); 216 | --muted-border-color: hsl(205, 20%, 94%); 217 | --primary: hsl(195, 85%, 41%); 218 | --primary-hover: hsl(195, 90%, 32%); 219 | --primary-focus: rgba(16, 149, 193, 0.125); 220 | --primary-inverse: #fff; 221 | --secondary: hsl(205, 15%, 41%); 222 | --secondary-hover: hsl(205, 20%, 32%); 223 | --secondary-focus: rgba(89, 107, 120, 0.125); 224 | --secondary-inverse: #fff; 225 | --contrast: hsl(205, 30%, 15%); 226 | --contrast-hover: #000; 227 | --contrast-focus: rgba(89, 107, 120, 0.125); 228 | --contrast-inverse: #fff; 229 | --mark-background-color: #fff2ca; 230 | --mark-color: #543a26; 231 | --ins-color: #388e3c; 232 | --del-color: #c62828; 233 | --blockquote-border-color: var(--muted-border-color); 234 | --blockquote-footer-color: var(--muted-color); 235 | --button-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 236 | --button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 237 | --form-element-background-color: transparent; 238 | --form-element-border-color: hsl(205, 14%, 68%); 239 | --form-element-color: var(--color); 240 | --form-element-placeholder-color: var(--muted-color); 241 | --form-element-active-background-color: transparent; 242 | --form-element-active-border-color: var(--primary); 243 | --form-element-focus-color: var(--primary-focus); 244 | --form-element-disabled-background-color: hsl(205, 18%, 86%); 245 | --form-element-disabled-border-color: hsl(205, 14%, 68%); 246 | --form-element-disabled-opacity: 0.5; 247 | --form-element-invalid-border-color: #c62828; 248 | --form-element-invalid-active-border-color: #d32f2f; 249 | --form-element-invalid-focus-color: rgba(211, 47, 47, 0.125); 250 | --form-element-valid-border-color: #388e3c; 251 | --form-element-valid-active-border-color: #43a047; 252 | --form-element-valid-focus-color: rgba(67, 160, 71, 0.125); 253 | --switch-background-color: hsl(205, 16%, 77%); 254 | --switch-color: var(--primary-inverse); 255 | --switch-checked-background-color: var(--primary); 256 | --range-border-color: hsl(205, 18%, 86%); 257 | --range-active-border-color: hsl(205, 16%, 77%); 258 | --range-thumb-border-color: var(--background-color); 259 | --range-thumb-color: var(--secondary); 260 | --range-thumb-hover-color: var(--secondary-hover); 261 | --range-thumb-active-color: var(--primary); 262 | --table-border-color: var(--muted-border-color); 263 | --table-row-stripped-background-color: #f6f8f9; 264 | --code-background-color: hsl(205, 20%, 94%); 265 | --code-color: var(--muted-color); 266 | --code-kbd-background-color: var(--contrast); 267 | --code-kbd-color: var(--contrast-inverse); 268 | --code-tag-color: hsl(330, 40%, 50%); 269 | --code-property-color: hsl(185, 40%, 40%); 270 | --code-value-color: hsl(40, 20%, 50%); 271 | --code-comment-color: hsl(205, 14%, 68%); 272 | --accordion-border-color: var(--muted-border-color); 273 | --accordion-close-summary-color: var(--color); 274 | --accordion-open-summary-color: var(--muted-color); 275 | --card-background-color: var(--background-color); 276 | --card-border-color: var(--muted-border-color); 277 | --card-box-shadow: 278 | 0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698), 279 | 0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024), 280 | 0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03), 281 | 0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036), 282 | 0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302), 283 | 0.5rem 1rem 6rem rgba(27, 40, 50, 0.06), 284 | 0 0 0 0.0625rem rgba(27, 40, 50, 0.015); 285 | --card-sectionning-background-color: #fbfbfc; 286 | --dropdown-background-color: #fbfbfc; 287 | --dropdown-border-color: #e1e6eb; 288 | --dropdown-box-shadow: var(--card-box-shadow); 289 | --dropdown-color: var(--color); 290 | --dropdown-hover-background-color: hsl(205, 20%, 94%); 291 | --modal-overlay-background-color: rgba(213, 220, 226, 0.7); 292 | --progress-background-color: hsl(205, 18%, 86%); 293 | --progress-color: var(--primary); 294 | --loading-spinner-opacity: 0.5; 295 | --tooltip-background-color: var(--contrast); 296 | --tooltip-color: var(--contrast-inverse); 297 | --icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 298 | --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 299 | --icon-chevron-button: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 300 | --icon-chevron-button-inverse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 301 | --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); 302 | --icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); 303 | --icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(198, 40, 40)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 304 | --icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); 305 | --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); 306 | --icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E"); 307 | --icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(56, 142, 60)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 308 | color-scheme: light; 309 | } 310 | 311 | @media only screen and (prefers-color-scheme: dark) { 312 | :root:not([data-theme]) { 313 | --background-color: #11191f; 314 | --color: hsl(205, 16%, 77%); 315 | --h1-color: hsl(205, 20%, 94%); 316 | --h2-color: #e1e6eb; 317 | --h3-color: hsl(205, 18%, 86%); 318 | --h4-color: #c8d1d8; 319 | --h5-color: hsl(205, 16%, 77%); 320 | --h6-color: #afbbc4; 321 | --muted-color: hsl(205, 10%, 50%); 322 | --muted-border-color: #1f2d38; 323 | --primary: hsl(195, 85%, 41%); 324 | --primary-hover: hsl(195, 80%, 50%); 325 | --primary-focus: rgba(16, 149, 193, 0.25); 326 | --primary-inverse: #fff; 327 | --secondary: hsl(205, 15%, 41%); 328 | --secondary-hover: hsl(205, 10%, 50%); 329 | --secondary-focus: rgba(115, 130, 140, 0.25); 330 | --secondary-inverse: #fff; 331 | --contrast: hsl(205, 20%, 94%); 332 | --contrast-hover: #fff; 333 | --contrast-focus: rgba(115, 130, 140, 0.25); 334 | --contrast-inverse: #000; 335 | --mark-background-color: #d1c284; 336 | --mark-color: #11191f; 337 | --ins-color: #388e3c; 338 | --del-color: #c62828; 339 | --blockquote-border-color: var(--muted-border-color); 340 | --blockquote-footer-color: var(--muted-color); 341 | --button-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 342 | --button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 343 | --form-element-background-color: #11191f; 344 | --form-element-border-color: #374956; 345 | --form-element-color: var(--color); 346 | --form-element-placeholder-color: var(--muted-color); 347 | --form-element-active-background-color: var(--form-element-background-color); 348 | --form-element-active-border-color: var(--primary); 349 | --form-element-focus-color: var(--primary-focus); 350 | --form-element-disabled-background-color: hsl(205, 25%, 23%); 351 | --form-element-disabled-border-color: hsl(205, 20%, 32%); 352 | --form-element-disabled-opacity: 0.5; 353 | --form-element-invalid-border-color: #b71c1c; 354 | --form-element-invalid-active-border-color: #c62828; 355 | --form-element-invalid-focus-color: rgba(198, 40, 40, 0.25); 356 | --form-element-valid-border-color: #2e7d32; 357 | --form-element-valid-active-border-color: #388e3c; 358 | --form-element-valid-focus-color: rgba(56, 142, 60, 0.25); 359 | --switch-background-color: #374956; 360 | --switch-color: var(--primary-inverse); 361 | --switch-checked-background-color: var(--primary); 362 | --range-border-color: #24333e; 363 | --range-active-border-color: hsl(205, 25%, 23%); 364 | --range-thumb-border-color: var(--background-color); 365 | --range-thumb-color: var(--secondary); 366 | --range-thumb-hover-color: var(--secondary-hover); 367 | --range-thumb-active-color: var(--primary); 368 | --table-border-color: var(--muted-border-color); 369 | --table-row-stripped-background-color: rgba(115, 130, 140, 0.05); 370 | --code-background-color: #18232c; 371 | --code-color: var(--muted-color); 372 | --code-kbd-background-color: var(--contrast); 373 | --code-kbd-color: var(--contrast-inverse); 374 | --code-tag-color: hsl(330, 30%, 50%); 375 | --code-property-color: hsl(185, 30%, 50%); 376 | --code-value-color: hsl(40, 10%, 50%); 377 | --code-comment-color: #4d606d; 378 | --accordion-border-color: var(--muted-border-color); 379 | --accordion-active-summary-color: var(--primary); 380 | --accordion-close-summary-color: var(--color); 381 | --accordion-open-summary-color: var(--muted-color); 382 | --card-background-color: #141e26; 383 | --card-border-color: var(--card-background-color); 384 | --card-box-shadow: 385 | 0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698), 386 | 0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024), 387 | 0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03), 388 | 0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036), 389 | 0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302), 390 | 0.5rem 1rem 6rem rgba(0, 0, 0, 0.06), 391 | 0 0 0 0.0625rem rgba(0, 0, 0, 0.015); 392 | --card-sectionning-background-color: #18232c; 393 | --dropdown-background-color: hsl(205, 30%, 15%); 394 | --dropdown-border-color: #24333e; 395 | --dropdown-box-shadow: var(--card-box-shadow); 396 | --dropdown-color: var(--color); 397 | --dropdown-hover-background-color: rgba(36, 51, 62, 0.75); 398 | --modal-overlay-background-color: rgba(36, 51, 62, 0.8); 399 | --progress-background-color: #24333e; 400 | --progress-color: var(--primary); 401 | --loading-spinner-opacity: 0.5; 402 | --tooltip-background-color: var(--contrast); 403 | --tooltip-color: var(--contrast-inverse); 404 | --icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 405 | --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 406 | --icon-chevron-button: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 407 | --icon-chevron-button-inverse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 408 | --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); 409 | --icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); 410 | --icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 411 | --icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); 412 | --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); 413 | --icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E"); 414 | --icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 415 | color-scheme: dark; 416 | } 417 | } 418 | [data-theme=dark] { 419 | --background-color: #11191f; 420 | --color: hsl(205, 16%, 77%); 421 | --h1-color: hsl(205, 20%, 94%); 422 | --h2-color: #e1e6eb; 423 | --h3-color: hsl(205, 18%, 86%); 424 | --h4-color: #c8d1d8; 425 | --h5-color: hsl(205, 16%, 77%); 426 | --h6-color: #afbbc4; 427 | --muted-color: hsl(205, 10%, 50%); 428 | --muted-border-color: #1f2d38; 429 | --primary: hsl(195, 85%, 41%); 430 | --primary-hover: hsl(195, 80%, 50%); 431 | --primary-focus: rgba(16, 149, 193, 0.25); 432 | --primary-inverse: #fff; 433 | --secondary: hsl(205, 15%, 41%); 434 | --secondary-hover: hsl(205, 10%, 50%); 435 | --secondary-focus: rgba(115, 130, 140, 0.25); 436 | --secondary-inverse: #fff; 437 | --contrast: hsl(205, 20%, 94%); 438 | --contrast-hover: #fff; 439 | --contrast-focus: rgba(115, 130, 140, 0.25); 440 | --contrast-inverse: #000; 441 | --mark-background-color: #d1c284; 442 | --mark-color: #11191f; 443 | --ins-color: #388e3c; 444 | --del-color: #c62828; 445 | --blockquote-border-color: var(--muted-border-color); 446 | --blockquote-footer-color: var(--muted-color); 447 | --button-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 448 | --button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 449 | --form-element-background-color: #11191f; 450 | --form-element-border-color: #374956; 451 | --form-element-color: var(--color); 452 | --form-element-placeholder-color: var(--muted-color); 453 | --form-element-active-background-color: var(--form-element-background-color); 454 | --form-element-active-border-color: var(--primary); 455 | --form-element-focus-color: var(--primary-focus); 456 | --form-element-disabled-background-color: hsl(205, 25%, 23%); 457 | --form-element-disabled-border-color: hsl(205, 20%, 32%); 458 | --form-element-disabled-opacity: 0.5; 459 | --form-element-invalid-border-color: #b71c1c; 460 | --form-element-invalid-active-border-color: #c62828; 461 | --form-element-invalid-focus-color: rgba(198, 40, 40, 0.25); 462 | --form-element-valid-border-color: #2e7d32; 463 | --form-element-valid-active-border-color: #388e3c; 464 | --form-element-valid-focus-color: rgba(56, 142, 60, 0.25); 465 | --switch-background-color: #374956; 466 | --switch-color: var(--primary-inverse); 467 | --switch-checked-background-color: var(--primary); 468 | --range-border-color: #24333e; 469 | --range-active-border-color: hsl(205, 25%, 23%); 470 | --range-thumb-border-color: var(--background-color); 471 | --range-thumb-color: var(--secondary); 472 | --range-thumb-hover-color: var(--secondary-hover); 473 | --range-thumb-active-color: var(--primary); 474 | --table-border-color: var(--muted-border-color); 475 | --table-row-stripped-background-color: rgba(115, 130, 140, 0.05); 476 | --code-background-color: #18232c; 477 | --code-color: var(--muted-color); 478 | --code-kbd-background-color: var(--contrast); 479 | --code-kbd-color: var(--contrast-inverse); 480 | --code-tag-color: hsl(330, 30%, 50%); 481 | --code-property-color: hsl(185, 30%, 50%); 482 | --code-value-color: hsl(40, 10%, 50%); 483 | --code-comment-color: #4d606d; 484 | --accordion-border-color: var(--muted-border-color); 485 | --accordion-active-summary-color: var(--primary); 486 | --accordion-close-summary-color: var(--color); 487 | --accordion-open-summary-color: var(--muted-color); 488 | --card-background-color: #141e26; 489 | --card-border-color: var(--card-background-color); 490 | --card-box-shadow: 491 | 0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698), 492 | 0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024), 493 | 0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03), 494 | 0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036), 495 | 0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302), 496 | 0.5rem 1rem 6rem rgba(0, 0, 0, 0.06), 497 | 0 0 0 0.0625rem rgba(0, 0, 0, 0.015); 498 | --card-sectionning-background-color: #18232c; 499 | --dropdown-background-color: hsl(205, 30%, 15%); 500 | --dropdown-border-color: #24333e; 501 | --dropdown-box-shadow: var(--card-box-shadow); 502 | --dropdown-color: var(--color); 503 | --dropdown-hover-background-color: rgba(36, 51, 62, 0.75); 504 | --modal-overlay-background-color: rgba(36, 51, 62, 0.8); 505 | --progress-background-color: #24333e; 506 | --progress-color: var(--primary); 507 | --loading-spinner-opacity: 0.5; 508 | --tooltip-background-color: var(--contrast); 509 | --tooltip-color: var(--contrast-inverse); 510 | --icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 511 | --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 512 | --icon-chevron-button: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 513 | --icon-chevron-button-inverse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 514 | --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); 515 | --icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); 516 | --icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 517 | --icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); 518 | --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); 519 | --icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E"); 520 | --icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 521 | color-scheme: dark; 522 | } 523 | 524 | progress, 525 | [type=checkbox], 526 | [type=radio], 527 | [type=range] { 528 | accent-color: var(--primary); 529 | } 530 | 531 | /** 532 | * Document 533 | * Content-box & Responsive typography 534 | */ 535 | *, 536 | *::before, 537 | *::after { 538 | box-sizing: border-box; 539 | background-repeat: no-repeat; 540 | } 541 | 542 | ::before, 543 | ::after { 544 | text-decoration: inherit; 545 | vertical-align: inherit; 546 | } 547 | 548 | :where(:root) { 549 | -webkit-tap-highlight-color: transparent; 550 | -webkit-text-size-adjust: 100%; 551 | -moz-text-size-adjust: 100%; 552 | text-size-adjust: 100%; 553 | background-color: var(--background-color); 554 | color: var(--color); 555 | font-weight: var(--font-weight); 556 | font-size: var(--font-size); 557 | line-height: var(--line-height); 558 | font-family: var(--font-family); 559 | text-rendering: optimizeLegibility; 560 | overflow-wrap: break-word; 561 | cursor: default; 562 | -moz-tab-size: 4; 563 | -o-tab-size: 4; 564 | tab-size: 4; 565 | } 566 | 567 | /** 568 | * Sectioning 569 | * Container and responsive spacings for header, main, footer 570 | */ 571 | main { 572 | display: block; 573 | } 574 | 575 | body { 576 | width: 100%; 577 | margin: 0; 578 | } 579 | body > header, 580 | body > main, 581 | body > footer { 582 | width: 100%; 583 | margin-right: auto; 584 | margin-left: auto; 585 | padding: var(--block-spacing-vertical) 0; 586 | } 587 | 588 | /** 589 | * Container 590 | */ 591 | .container, 592 | .container-fluid { 593 | width: 100%; 594 | margin-right: auto; 595 | margin-left: auto; 596 | padding-right: var(--spacing); 597 | padding-left: var(--spacing); 598 | } 599 | 600 | @media (min-width: 576px) { 601 | .container { 602 | max-width: 510px; 603 | padding-right: 0; 604 | padding-left: 0; 605 | } 606 | } 607 | @media (min-width: 768px) { 608 | .container { 609 | max-width: 700px; 610 | } 611 | } 612 | @media (min-width: 992px) { 613 | .container { 614 | max-width: 920px; 615 | } 616 | } 617 | @media (min-width: 1200px) { 618 | .container { 619 | max-width: 1130px; 620 | } 621 | } 622 | 623 | /** 624 | * Section 625 | * Responsive spacings for section 626 | */ 627 | section { 628 | margin-bottom: var(--block-spacing-vertical); 629 | } 630 | 631 | /** 632 | * Grid 633 | * Minimal grid system with auto-layout columns 634 | */ 635 | .grid { 636 | grid-column-gap: var(--grid-spacing-horizontal); 637 | grid-row-gap: var(--grid-spacing-vertical); 638 | display: grid; 639 | grid-template-columns: 1fr; 640 | margin: 0; 641 | } 642 | @media (min-width: 992px) { 643 | .grid { 644 | grid-template-columns: repeat(auto-fit, minmax(0%, 1fr)); 645 | } 646 | } 647 | .grid > * { 648 | min-width: 0; 649 | } 650 | 651 | /** 652 | * Horizontal scroller (
) 653 | */ 654 | figure { 655 | display: block; 656 | margin: 0; 657 | padding: 0; 658 | overflow-x: auto; 659 | } 660 | figure figcaption { 661 | padding: calc(var(--spacing) * 0.5) 0; 662 | color: var(--muted-color); 663 | } 664 | 665 | /** 666 | * Typography 667 | */ 668 | b, 669 | strong { 670 | font-weight: bolder; 671 | } 672 | 673 | sub, 674 | sup { 675 | position: relative; 676 | font-size: 0.75em; 677 | line-height: 0; 678 | vertical-align: baseline; 679 | } 680 | 681 | sub { 682 | bottom: -0.25em; 683 | } 684 | 685 | sup { 686 | top: -0.5em; 687 | } 688 | 689 | address, 690 | blockquote, 691 | dl, 692 | figure, 693 | form, 694 | ol, 695 | p, 696 | pre, 697 | table, 698 | ul { 699 | margin-top: 0; 700 | margin-bottom: var(--typography-spacing-vertical); 701 | color: var(--color); 702 | font-style: normal; 703 | font-weight: var(--font-weight); 704 | font-size: var(--font-size); 705 | } 706 | 707 | a, 708 | [role=link] { 709 | --color: var(--primary); 710 | --background-color: transparent; 711 | outline: none; 712 | background-color: var(--background-color); 713 | color: var(--color); 714 | -webkit-text-decoration: var(--text-decoration); 715 | text-decoration: var(--text-decoration); 716 | transition: background-color var(--transition), color var(--transition), box-shadow var(--transition), -webkit-text-decoration var(--transition); 717 | transition: background-color var(--transition), color var(--transition), text-decoration var(--transition), box-shadow var(--transition); 718 | transition: background-color var(--transition), color var(--transition), text-decoration var(--transition), box-shadow var(--transition), -webkit-text-decoration var(--transition); 719 | } 720 | a:is([aria-current], :hover, :active, :focus), 721 | [role=link]:is([aria-current], :hover, :active, :focus) { 722 | --color: var(--primary-hover); 723 | --text-decoration: underline; 724 | } 725 | a:focus, 726 | [role=link]:focus { 727 | --background-color: var(--primary-focus); 728 | } 729 | a.secondary, 730 | [role=link].secondary { 731 | --color: var(--secondary); 732 | } 733 | a.secondary:is([aria-current], :hover, :active, :focus), 734 | [role=link].secondary:is([aria-current], :hover, :active, :focus) { 735 | --color: var(--secondary-hover); 736 | } 737 | a.secondary:focus, 738 | [role=link].secondary:focus { 739 | --background-color: var(--secondary-focus); 740 | } 741 | a.contrast, 742 | [role=link].contrast { 743 | --color: var(--contrast); 744 | } 745 | a.contrast:is([aria-current], :hover, :active, :focus), 746 | [role=link].contrast:is([aria-current], :hover, :active, :focus) { 747 | --color: var(--contrast-hover); 748 | } 749 | a.contrast:focus, 750 | [role=link].contrast:focus { 751 | --background-color: var(--contrast-focus); 752 | } 753 | 754 | h1, 755 | h2, 756 | h3, 757 | h4, 758 | h5, 759 | h6 { 760 | margin-top: 0; 761 | margin-bottom: var(--typography-spacing-vertical); 762 | color: var(--color); 763 | font-weight: var(--font-weight); 764 | font-size: var(--font-size); 765 | font-family: var(--font-family); 766 | } 767 | 768 | h1 { 769 | --color: var(--h1-color); 770 | } 771 | 772 | h2 { 773 | --color: var(--h2-color); 774 | } 775 | 776 | h3 { 777 | --color: var(--h3-color); 778 | } 779 | 780 | h4 { 781 | --color: var(--h4-color); 782 | } 783 | 784 | h5 { 785 | --color: var(--h5-color); 786 | } 787 | 788 | h6 { 789 | --color: var(--h6-color); 790 | } 791 | 792 | :where(address, blockquote, dl, figure, form, ol, p, pre, table, ul) ~ :is(h1, h2, h3, h4, h5, h6) { 793 | margin-top: var(--typography-spacing-vertical); 794 | } 795 | 796 | hgroup, 797 | .headings { 798 | margin-bottom: var(--typography-spacing-vertical); 799 | } 800 | hgroup > *, 801 | .headings > * { 802 | margin-bottom: 0; 803 | } 804 | hgroup > *:last-child, 805 | .headings > *:last-child { 806 | --color: var(--muted-color); 807 | --font-weight: unset; 808 | font-size: 1rem; 809 | font-family: unset; 810 | } 811 | 812 | p { 813 | margin-bottom: var(--typography-spacing-vertical); 814 | } 815 | 816 | small { 817 | font-size: var(--font-size); 818 | } 819 | 820 | :where(dl, ol, ul) { 821 | padding-right: 0; 822 | padding-left: var(--spacing); 823 | -webkit-padding-start: var(--spacing); 824 | padding-inline-start: var(--spacing); 825 | -webkit-padding-end: 0; 826 | padding-inline-end: 0; 827 | } 828 | :where(dl, ol, ul) li { 829 | margin-bottom: calc(var(--typography-spacing-vertical) * 0.25); 830 | } 831 | 832 | :where(dl, ol, ul) :is(dl, ol, ul) { 833 | margin: 0; 834 | margin-top: calc(var(--typography-spacing-vertical) * 0.25); 835 | } 836 | 837 | ul li { 838 | list-style: square; 839 | } 840 | 841 | mark { 842 | padding: 0.125rem 0.25rem; 843 | background-color: var(--mark-background-color); 844 | color: var(--mark-color); 845 | vertical-align: baseline; 846 | } 847 | 848 | blockquote { 849 | display: block; 850 | margin: var(--typography-spacing-vertical) 0; 851 | padding: var(--spacing); 852 | border-right: none; 853 | border-left: 0.25rem solid var(--blockquote-border-color); 854 | -webkit-border-start: 0.25rem solid var(--blockquote-border-color); 855 | border-inline-start: 0.25rem solid var(--blockquote-border-color); 856 | -webkit-border-end: none; 857 | border-inline-end: none; 858 | } 859 | blockquote footer { 860 | margin-top: calc(var(--typography-spacing-vertical) * 0.5); 861 | color: var(--blockquote-footer-color); 862 | } 863 | 864 | abbr[title] { 865 | border-bottom: 1px dotted; 866 | text-decoration: none; 867 | cursor: help; 868 | } 869 | 870 | ins { 871 | color: var(--ins-color); 872 | text-decoration: none; 873 | } 874 | 875 | del { 876 | color: var(--del-color); 877 | } 878 | 879 | ::-moz-selection { 880 | background-color: var(--primary-focus); 881 | } 882 | 883 | ::selection { 884 | background-color: var(--primary-focus); 885 | } 886 | 887 | /** 888 | * Embedded content 889 | */ 890 | :where(audio, canvas, iframe, img, svg, video) { 891 | vertical-align: middle; 892 | } 893 | 894 | audio, 895 | video { 896 | display: inline-block; 897 | } 898 | 899 | audio:not([controls]) { 900 | display: none; 901 | height: 0; 902 | } 903 | 904 | :where(iframe) { 905 | border-style: none; 906 | } 907 | 908 | img { 909 | max-width: 100%; 910 | height: auto; 911 | border-style: none; 912 | } 913 | 914 | :where(svg:not([fill])) { 915 | fill: currentColor; 916 | } 917 | 918 | svg:not(:root) { 919 | overflow: hidden; 920 | } 921 | 922 | /** 923 | * Button 924 | */ 925 | button { 926 | margin: 0; 927 | overflow: visible; 928 | font-family: inherit; 929 | text-transform: none; 930 | } 931 | 932 | button, 933 | [type=button], 934 | [type=reset], 935 | [type=submit] { 936 | -webkit-appearance: button; 937 | } 938 | 939 | button { 940 | display: block; 941 | width: 100%; 942 | margin-bottom: var(--spacing); 943 | } 944 | 945 | [role=button] { 946 | display: inline-block; 947 | text-decoration: none; 948 | } 949 | 950 | button, 951 | input[type=submit], 952 | input[type=button], 953 | input[type=reset], 954 | [role=button] { 955 | --background-color: var(--primary); 956 | --border-color: var(--primary); 957 | --color: var(--primary-inverse); 958 | --box-shadow: var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0)); 959 | padding: var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal); 960 | border: var(--border-width) solid var(--border-color); 961 | border-radius: var(--border-radius); 962 | outline: none; 963 | background-color: var(--background-color); 964 | box-shadow: var(--box-shadow); 965 | color: var(--color); 966 | font-weight: var(--font-weight); 967 | font-size: 1rem; 968 | line-height: var(--line-height); 969 | text-align: center; 970 | cursor: pointer; 971 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 972 | } 973 | button:is([aria-current], :hover, :active, :focus), 974 | input[type=submit]:is([aria-current], :hover, :active, :focus), 975 | input[type=button]:is([aria-current], :hover, :active, :focus), 976 | input[type=reset]:is([aria-current], :hover, :active, :focus), 977 | [role=button]:is([aria-current], :hover, :active, :focus) { 978 | --background-color: var(--primary-hover); 979 | --border-color: var(--primary-hover); 980 | --box-shadow: var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)); 981 | --color: var(--primary-inverse); 982 | } 983 | button:focus, 984 | input[type=submit]:focus, 985 | input[type=button]:focus, 986 | input[type=reset]:focus, 987 | [role=button]:focus { 988 | --box-shadow: var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), 989 | 0 0 0 var(--outline-width) var(--primary-focus); 990 | } 991 | 992 | :is(button, input[type=submit], input[type=button], [role=button]).secondary, 993 | input[type=reset] { 994 | --background-color: var(--secondary); 995 | --border-color: var(--secondary); 996 | --color: var(--secondary-inverse); 997 | cursor: pointer; 998 | } 999 | :is(button, input[type=submit], input[type=button], [role=button]).secondary:is([aria-current], :hover, :active, :focus), 1000 | input[type=reset]:is([aria-current], :hover, :active, :focus) { 1001 | --background-color: var(--secondary-hover); 1002 | --border-color: var(--secondary-hover); 1003 | --color: var(--secondary-inverse); 1004 | } 1005 | :is(button, input[type=submit], input[type=button], [role=button]).secondary:focus, 1006 | input[type=reset]:focus { 1007 | --box-shadow: var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), 1008 | 0 0 0 var(--outline-width) var(--secondary-focus); 1009 | } 1010 | 1011 | :is(button, input[type=submit], input[type=button], [role=button]).contrast { 1012 | --background-color: var(--contrast); 1013 | --border-color: var(--contrast); 1014 | --color: var(--contrast-inverse); 1015 | } 1016 | :is(button, input[type=submit], input[type=button], [role=button]).contrast:is([aria-current], :hover, :active, :focus) { 1017 | --background-color: var(--contrast-hover); 1018 | --border-color: var(--contrast-hover); 1019 | --color: var(--contrast-inverse); 1020 | } 1021 | :is(button, input[type=submit], input[type=button], [role=button]).contrast:focus { 1022 | --box-shadow: var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), 1023 | 0 0 0 var(--outline-width) var(--contrast-focus); 1024 | } 1025 | 1026 | :is(button, input[type=submit], input[type=button], [role=button]).outline, 1027 | input[type=reset].outline { 1028 | --background-color: transparent; 1029 | --color: var(--primary); 1030 | } 1031 | :is(button, input[type=submit], input[type=button], [role=button]).outline:is([aria-current], :hover, :active, :focus), 1032 | input[type=reset].outline:is([aria-current], :hover, :active, :focus) { 1033 | --background-color: transparent; 1034 | --color: var(--primary-hover); 1035 | } 1036 | 1037 | :is(button, input[type=submit], input[type=button], [role=button]).outline.secondary, 1038 | input[type=reset].outline { 1039 | --color: var(--secondary); 1040 | } 1041 | :is(button, input[type=submit], input[type=button], [role=button]).outline.secondary:is([aria-current], :hover, :active, :focus), 1042 | input[type=reset].outline:is([aria-current], :hover, :active, :focus) { 1043 | --color: var(--secondary-hover); 1044 | } 1045 | 1046 | :is(button, input[type=submit], input[type=button], [role=button]).outline.contrast { 1047 | --color: var(--contrast); 1048 | } 1049 | :is(button, input[type=submit], input[type=button], [role=button]).outline.contrast:is([aria-current], :hover, :active, :focus) { 1050 | --color: var(--contrast-hover); 1051 | } 1052 | 1053 | :where(button, [type=submit], [type=button], [type=reset], [role=button])[disabled], 1054 | :where(fieldset[disabled]) :is(button, [type=submit], [type=button], [type=reset], [role=button]), 1055 | a[role=button]:not([href]) { 1056 | opacity: 0.5; 1057 | pointer-events: none; 1058 | } 1059 | 1060 | /** 1061 | * Form elements 1062 | */ 1063 | input, 1064 | optgroup, 1065 | select, 1066 | textarea { 1067 | margin: 0; 1068 | font-size: 1rem; 1069 | line-height: var(--line-height); 1070 | font-family: inherit; 1071 | letter-spacing: inherit; 1072 | } 1073 | 1074 | input { 1075 | overflow: visible; 1076 | } 1077 | 1078 | select { 1079 | text-transform: none; 1080 | } 1081 | 1082 | legend { 1083 | max-width: 100%; 1084 | padding: 0; 1085 | color: inherit; 1086 | white-space: normal; 1087 | } 1088 | 1089 | textarea { 1090 | overflow: auto; 1091 | } 1092 | 1093 | [type=checkbox], 1094 | [type=radio] { 1095 | padding: 0; 1096 | } 1097 | 1098 | ::-webkit-inner-spin-button, 1099 | ::-webkit-outer-spin-button { 1100 | height: auto; 1101 | } 1102 | 1103 | [type=search] { 1104 | -webkit-appearance: textfield; 1105 | outline-offset: -2px; 1106 | } 1107 | 1108 | [type=search]::-webkit-search-decoration { 1109 | -webkit-appearance: none; 1110 | } 1111 | 1112 | ::-webkit-file-upload-button { 1113 | -webkit-appearance: button; 1114 | font: inherit; 1115 | } 1116 | 1117 | ::-moz-focus-inner { 1118 | padding: 0; 1119 | border-style: none; 1120 | } 1121 | 1122 | :-moz-focusring { 1123 | outline: none; 1124 | } 1125 | 1126 | :-moz-ui-invalid { 1127 | box-shadow: none; 1128 | } 1129 | 1130 | ::-ms-expand { 1131 | display: none; 1132 | } 1133 | 1134 | [type=file], 1135 | [type=range] { 1136 | padding: 0; 1137 | border-width: 0; 1138 | } 1139 | 1140 | input:not([type=checkbox], [type=radio], [type=range]) { 1141 | height: calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2); 1142 | } 1143 | 1144 | fieldset { 1145 | margin: 0; 1146 | margin-bottom: var(--spacing); 1147 | padding: 0; 1148 | border: 0; 1149 | } 1150 | 1151 | label, 1152 | fieldset legend { 1153 | display: block; 1154 | margin-bottom: calc(var(--spacing) * 0.25); 1155 | font-weight: var(--form-label-font-weight, var(--font-weight)); 1156 | } 1157 | 1158 | input:not([type=checkbox], [type=radio]), 1159 | select, 1160 | textarea { 1161 | width: 100%; 1162 | } 1163 | 1164 | input:not([type=checkbox], [type=radio], [type=range], [type=file]), 1165 | select, 1166 | textarea { 1167 | -webkit-appearance: none; 1168 | -moz-appearance: none; 1169 | appearance: none; 1170 | padding: var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal); 1171 | } 1172 | 1173 | input, 1174 | select, 1175 | textarea { 1176 | --background-color: var(--form-element-background-color); 1177 | --border-color: var(--form-element-border-color); 1178 | --color: var(--form-element-color); 1179 | --box-shadow: none; 1180 | border: var(--border-width) solid var(--border-color); 1181 | border-radius: var(--border-radius); 1182 | outline: none; 1183 | background-color: var(--background-color); 1184 | box-shadow: var(--box-shadow); 1185 | color: var(--color); 1186 | font-weight: var(--font-weight); 1187 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1188 | } 1189 | 1190 | input:not([type=submit], [type=button], [type=reset], [type=checkbox], [type=radio], [readonly]):is(:active, :focus), 1191 | :where(select, textarea):is(:active, :focus) { 1192 | --background-color: var(--form-element-active-background-color); 1193 | } 1194 | 1195 | input:not([type=submit], [type=button], [type=reset], [role=switch], [readonly]):is(:active, :focus), 1196 | :where(select, textarea):is(:active, :focus) { 1197 | --border-color: var(--form-element-active-border-color); 1198 | } 1199 | 1200 | input:not([type=submit], [type=button], [type=reset], [type=range], [type=file], [readonly]):focus, 1201 | select:focus, 1202 | textarea:focus { 1203 | --box-shadow: 0 0 0 var(--outline-width) var(--form-element-focus-color); 1204 | } 1205 | 1206 | input:not([type=submit], [type=button], [type=reset])[disabled], 1207 | select[disabled], 1208 | textarea[disabled], 1209 | :where(fieldset[disabled]) :is(input:not([type=submit], [type=button], [type=reset]), select, textarea) { 1210 | --background-color: var(--form-element-disabled-background-color); 1211 | --border-color: var(--form-element-disabled-border-color); 1212 | opacity: var(--form-element-disabled-opacity); 1213 | pointer-events: none; 1214 | } 1215 | 1216 | :where(input, select, textarea):not([type=checkbox], [type=radio], [type=date], [type=datetime-local], [type=month], [type=time], [type=week])[aria-invalid] { 1217 | padding-right: calc(var(--form-element-spacing-horizontal) + 1.5rem) !important; 1218 | padding-left: var(--form-element-spacing-horizontal); 1219 | -webkit-padding-start: var(--form-element-spacing-horizontal) !important; 1220 | padding-inline-start: var(--form-element-spacing-horizontal) !important; 1221 | -webkit-padding-end: calc(var(--form-element-spacing-horizontal) + 1.5rem) !important; 1222 | padding-inline-end: calc(var(--form-element-spacing-horizontal) + 1.5rem) !important; 1223 | background-position: center right 0.75rem; 1224 | background-size: 1rem auto; 1225 | background-repeat: no-repeat; 1226 | } 1227 | :where(input, select, textarea):not([type=checkbox], [type=radio], [type=date], [type=datetime-local], [type=month], [type=time], [type=week])[aria-invalid=false] { 1228 | background-image: var(--icon-valid); 1229 | } 1230 | :where(input, select, textarea):not([type=checkbox], [type=radio], [type=date], [type=datetime-local], [type=month], [type=time], [type=week])[aria-invalid=true] { 1231 | background-image: var(--icon-invalid); 1232 | } 1233 | :where(input, select, textarea)[aria-invalid=false] { 1234 | --border-color: var(--form-element-valid-border-color); 1235 | } 1236 | :where(input, select, textarea)[aria-invalid=false]:is(:active, :focus) { 1237 | --border-color: var(--form-element-valid-active-border-color) !important; 1238 | --box-shadow: 0 0 0 var(--outline-width) var(--form-element-valid-focus-color) !important; 1239 | } 1240 | :where(input, select, textarea)[aria-invalid=true] { 1241 | --border-color: var(--form-element-invalid-border-color); 1242 | } 1243 | :where(input, select, textarea)[aria-invalid=true]:is(:active, :focus) { 1244 | --border-color: var(--form-element-invalid-active-border-color) !important; 1245 | --box-shadow: 0 0 0 var(--outline-width) var(--form-element-invalid-focus-color) !important; 1246 | } 1247 | 1248 | [dir=rtl] :where(input, select, textarea):not([type=checkbox], [type=radio]):is([aria-invalid], [aria-invalid=true], [aria-invalid=false]) { 1249 | background-position: center left 0.75rem; 1250 | } 1251 | 1252 | input::placeholder, 1253 | input::-webkit-input-placeholder, 1254 | textarea::placeholder, 1255 | textarea::-webkit-input-placeholder, 1256 | select:invalid { 1257 | color: var(--form-element-placeholder-color); 1258 | opacity: 1; 1259 | } 1260 | 1261 | input:not([type=checkbox], [type=radio]), 1262 | select, 1263 | textarea { 1264 | margin-bottom: var(--spacing); 1265 | } 1266 | 1267 | select::-ms-expand { 1268 | border: 0; 1269 | background-color: transparent; 1270 | } 1271 | select:not([multiple], [size]) { 1272 | padding-right: calc(var(--form-element-spacing-horizontal) + 1.5rem); 1273 | padding-left: var(--form-element-spacing-horizontal); 1274 | -webkit-padding-start: var(--form-element-spacing-horizontal); 1275 | padding-inline-start: var(--form-element-spacing-horizontal); 1276 | -webkit-padding-end: calc(var(--form-element-spacing-horizontal) + 1.5rem); 1277 | padding-inline-end: calc(var(--form-element-spacing-horizontal) + 1.5rem); 1278 | background-image: var(--icon-chevron); 1279 | background-position: center right 0.75rem; 1280 | background-size: 1rem auto; 1281 | background-repeat: no-repeat; 1282 | } 1283 | 1284 | [dir=rtl] select:not([multiple], [size]) { 1285 | background-position: center left 0.75rem; 1286 | } 1287 | 1288 | :where(input, select, textarea, .grid) + small { 1289 | display: block; 1290 | width: 100%; 1291 | margin-top: calc(var(--spacing) * -0.75); 1292 | margin-bottom: var(--spacing); 1293 | color: var(--muted-color); 1294 | } 1295 | 1296 | label > :where(input, select, textarea) { 1297 | margin-top: calc(var(--spacing) * 0.25); 1298 | } 1299 | 1300 | /** 1301 | * Form elements 1302 | * Checkboxes & Radios 1303 | */ 1304 | [type=checkbox], 1305 | [type=radio] { 1306 | -webkit-appearance: none; 1307 | -moz-appearance: none; 1308 | appearance: none; 1309 | width: 1.25em; 1310 | height: 1.25em; 1311 | margin-top: -0.125em; 1312 | margin-right: 0.375em; 1313 | margin-left: 0; 1314 | -webkit-margin-start: 0; 1315 | margin-inline-start: 0; 1316 | -webkit-margin-end: 0.375em; 1317 | margin-inline-end: 0.375em; 1318 | border-width: var(--border-width); 1319 | font-size: inherit; 1320 | vertical-align: middle; 1321 | cursor: pointer; 1322 | } 1323 | [type=checkbox]::-ms-check, 1324 | [type=radio]::-ms-check { 1325 | display: none; 1326 | } 1327 | [type=checkbox]:checked, [type=checkbox]:checked:active, [type=checkbox]:checked:focus, 1328 | [type=radio]:checked, 1329 | [type=radio]:checked:active, 1330 | [type=radio]:checked:focus { 1331 | --background-color: var(--primary); 1332 | --border-color: var(--primary); 1333 | background-image: var(--icon-checkbox); 1334 | background-position: center; 1335 | background-size: 0.75em auto; 1336 | background-repeat: no-repeat; 1337 | } 1338 | [type=checkbox] ~ label, 1339 | [type=radio] ~ label { 1340 | display: inline-block; 1341 | margin-right: 0.375em; 1342 | margin-bottom: 0; 1343 | cursor: pointer; 1344 | } 1345 | 1346 | [type=checkbox]:indeterminate { 1347 | --background-color: var(--primary); 1348 | --border-color: var(--primary); 1349 | background-image: var(--icon-minus); 1350 | background-position: center; 1351 | background-size: 0.75em auto; 1352 | background-repeat: no-repeat; 1353 | } 1354 | 1355 | [type=radio] { 1356 | border-radius: 50%; 1357 | } 1358 | [type=radio]:checked, [type=radio]:checked:active, [type=radio]:checked:focus { 1359 | --background-color: var(--primary-inverse); 1360 | border-width: 0.35em; 1361 | background-image: none; 1362 | } 1363 | 1364 | [type=checkbox][role=switch] { 1365 | --background-color: var(--switch-background-color); 1366 | --border-color: var(--switch-background-color); 1367 | --color: var(--switch-color); 1368 | width: 2.25em; 1369 | height: 1.25em; 1370 | border: var(--border-width) solid var(--border-color); 1371 | border-radius: 1.25em; 1372 | background-color: var(--background-color); 1373 | line-height: 1.25em; 1374 | } 1375 | [type=checkbox][role=switch]:focus { 1376 | --background-color: var(--switch-background-color); 1377 | --border-color: var(--switch-background-color); 1378 | } 1379 | [type=checkbox][role=switch]:checked { 1380 | --background-color: var(--switch-checked-background-color); 1381 | --border-color: var(--switch-checked-background-color); 1382 | } 1383 | [type=checkbox][role=switch]:before { 1384 | display: block; 1385 | width: calc(1.25em - (var(--border-width) * 2)); 1386 | height: 100%; 1387 | border-radius: 50%; 1388 | background-color: var(--color); 1389 | content: ""; 1390 | transition: margin 0.1s ease-in-out; 1391 | } 1392 | [type=checkbox][role=switch]:checked { 1393 | background-image: none; 1394 | } 1395 | [type=checkbox][role=switch]:checked::before { 1396 | margin-left: calc(1.125em - var(--border-width)); 1397 | -webkit-margin-start: calc(1.125em - var(--border-width)); 1398 | margin-inline-start: calc(1.125em - var(--border-width)); 1399 | } 1400 | 1401 | [type=checkbox][aria-invalid=false], 1402 | [type=checkbox]:checked[aria-invalid=false], 1403 | [type=radio][aria-invalid=false], 1404 | [type=radio]:checked[aria-invalid=false], 1405 | [type=checkbox][role=switch][aria-invalid=false], 1406 | [type=checkbox][role=switch]:checked[aria-invalid=false] { 1407 | --border-color: var(--form-element-valid-border-color); 1408 | } 1409 | [type=checkbox][aria-invalid=true], 1410 | [type=checkbox]:checked[aria-invalid=true], 1411 | [type=radio][aria-invalid=true], 1412 | [type=radio]:checked[aria-invalid=true], 1413 | [type=checkbox][role=switch][aria-invalid=true], 1414 | [type=checkbox][role=switch]:checked[aria-invalid=true] { 1415 | --border-color: var(--form-element-invalid-border-color); 1416 | } 1417 | 1418 | /** 1419 | * Form elements 1420 | * Alternatives input types (Not Checkboxes & Radios) 1421 | */ 1422 | [type=color]::-webkit-color-swatch-wrapper { 1423 | padding: 0; 1424 | } 1425 | [type=color]::-moz-focus-inner { 1426 | padding: 0; 1427 | } 1428 | [type=color]::-webkit-color-swatch { 1429 | border: 0; 1430 | border-radius: calc(var(--border-radius) * 0.5); 1431 | } 1432 | [type=color]::-moz-color-swatch { 1433 | border: 0; 1434 | border-radius: calc(var(--border-radius) * 0.5); 1435 | } 1436 | 1437 | input:not([type=checkbox], [type=radio], [type=range], [type=file]):is([type=date], [type=datetime-local], [type=month], [type=time], [type=week]) { 1438 | --icon-position: 0.75rem; 1439 | --icon-width: 1rem; 1440 | padding-right: calc(var(--icon-width) + var(--icon-position)); 1441 | background-image: var(--icon-date); 1442 | background-position: center right var(--icon-position); 1443 | background-size: var(--icon-width) auto; 1444 | background-repeat: no-repeat; 1445 | } 1446 | input:not([type=checkbox], [type=radio], [type=range], [type=file])[type=time] { 1447 | background-image: var(--icon-time); 1448 | } 1449 | 1450 | [type=date]::-webkit-calendar-picker-indicator, 1451 | [type=datetime-local]::-webkit-calendar-picker-indicator, 1452 | [type=month]::-webkit-calendar-picker-indicator, 1453 | [type=time]::-webkit-calendar-picker-indicator, 1454 | [type=week]::-webkit-calendar-picker-indicator { 1455 | width: var(--icon-width); 1456 | margin-right: calc(var(--icon-width) * -1); 1457 | margin-left: var(--icon-position); 1458 | opacity: 0; 1459 | } 1460 | 1461 | [dir=rtl] :is([type=date], [type=datetime-local], [type=month], [type=time], [type=week]) { 1462 | text-align: right; 1463 | } 1464 | 1465 | [type=file] { 1466 | --color: var(--muted-color); 1467 | padding: calc(var(--form-element-spacing-vertical) * 0.5) 0; 1468 | border: 0; 1469 | border-radius: 0; 1470 | background: none; 1471 | } 1472 | [type=file]::file-selector-button { 1473 | --background-color: var(--secondary); 1474 | --border-color: var(--secondary); 1475 | --color: var(--secondary-inverse); 1476 | margin-right: calc(var(--spacing) / 2); 1477 | margin-left: 0; 1478 | -webkit-margin-start: 0; 1479 | margin-inline-start: 0; 1480 | -webkit-margin-end: calc(var(--spacing) / 2); 1481 | margin-inline-end: calc(var(--spacing) / 2); 1482 | padding: calc(var(--form-element-spacing-vertical) * 0.5) calc(var(--form-element-spacing-horizontal) * 0.5); 1483 | border: var(--border-width) solid var(--border-color); 1484 | border-radius: var(--border-radius); 1485 | outline: none; 1486 | background-color: var(--background-color); 1487 | box-shadow: var(--box-shadow); 1488 | color: var(--color); 1489 | font-weight: var(--font-weight); 1490 | font-size: 1rem; 1491 | line-height: var(--line-height); 1492 | text-align: center; 1493 | cursor: pointer; 1494 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1495 | } 1496 | [type=file]::file-selector-button:is(:hover, :active, :focus) { 1497 | --background-color: var(--secondary-hover); 1498 | --border-color: var(--secondary-hover); 1499 | } 1500 | [type=file]::-webkit-file-upload-button { 1501 | --background-color: var(--secondary); 1502 | --border-color: var(--secondary); 1503 | --color: var(--secondary-inverse); 1504 | margin-right: calc(var(--spacing) / 2); 1505 | margin-left: 0; 1506 | -webkit-margin-start: 0; 1507 | margin-inline-start: 0; 1508 | -webkit-margin-end: calc(var(--spacing) / 2); 1509 | margin-inline-end: calc(var(--spacing) / 2); 1510 | padding: calc(var(--form-element-spacing-vertical) * 0.5) calc(var(--form-element-spacing-horizontal) * 0.5); 1511 | border: var(--border-width) solid var(--border-color); 1512 | border-radius: var(--border-radius); 1513 | outline: none; 1514 | background-color: var(--background-color); 1515 | box-shadow: var(--box-shadow); 1516 | color: var(--color); 1517 | font-weight: var(--font-weight); 1518 | font-size: 1rem; 1519 | line-height: var(--line-height); 1520 | text-align: center; 1521 | cursor: pointer; 1522 | -webkit-transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1523 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1524 | } 1525 | [type=file]::-webkit-file-upload-button:is(:hover, :active, :focus) { 1526 | --background-color: var(--secondary-hover); 1527 | --border-color: var(--secondary-hover); 1528 | } 1529 | [type=file]::-ms-browse { 1530 | --background-color: var(--secondary); 1531 | --border-color: var(--secondary); 1532 | --color: var(--secondary-inverse); 1533 | margin-right: calc(var(--spacing) / 2); 1534 | margin-left: 0; 1535 | margin-inline-start: 0; 1536 | margin-inline-end: calc(var(--spacing) / 2); 1537 | padding: calc(var(--form-element-spacing-vertical) * 0.5) calc(var(--form-element-spacing-horizontal) * 0.5); 1538 | border: var(--border-width) solid var(--border-color); 1539 | border-radius: var(--border-radius); 1540 | outline: none; 1541 | background-color: var(--background-color); 1542 | box-shadow: var(--box-shadow); 1543 | color: var(--color); 1544 | font-weight: var(--font-weight); 1545 | font-size: 1rem; 1546 | line-height: var(--line-height); 1547 | text-align: center; 1548 | cursor: pointer; 1549 | -ms-transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1550 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 1551 | } 1552 | [type=file]::-ms-browse:is(:hover, :active, :focus) { 1553 | --background-color: var(--secondary-hover); 1554 | --border-color: var(--secondary-hover); 1555 | } 1556 | 1557 | [type=range] { 1558 | -webkit-appearance: none; 1559 | -moz-appearance: none; 1560 | appearance: none; 1561 | width: 100%; 1562 | height: 1.25rem; 1563 | background: none; 1564 | } 1565 | [type=range]::-webkit-slider-runnable-track { 1566 | width: 100%; 1567 | height: 0.25rem; 1568 | border-radius: var(--border-radius); 1569 | background-color: var(--range-border-color); 1570 | -webkit-transition: background-color var(--transition), box-shadow var(--transition); 1571 | transition: background-color var(--transition), box-shadow var(--transition); 1572 | } 1573 | [type=range]::-moz-range-track { 1574 | width: 100%; 1575 | height: 0.25rem; 1576 | border-radius: var(--border-radius); 1577 | background-color: var(--range-border-color); 1578 | -moz-transition: background-color var(--transition), box-shadow var(--transition); 1579 | transition: background-color var(--transition), box-shadow var(--transition); 1580 | } 1581 | [type=range]::-ms-track { 1582 | width: 100%; 1583 | height: 0.25rem; 1584 | border-radius: var(--border-radius); 1585 | background-color: var(--range-border-color); 1586 | -ms-transition: background-color var(--transition), box-shadow var(--transition); 1587 | transition: background-color var(--transition), box-shadow var(--transition); 1588 | } 1589 | [type=range]::-webkit-slider-thumb { 1590 | -webkit-appearance: none; 1591 | width: 1.25rem; 1592 | height: 1.25rem; 1593 | margin-top: -0.5rem; 1594 | border: 2px solid var(--range-thumb-border-color); 1595 | border-radius: 50%; 1596 | background-color: var(--range-thumb-color); 1597 | cursor: pointer; 1598 | -webkit-transition: background-color var(--transition), transform var(--transition); 1599 | transition: background-color var(--transition), transform var(--transition); 1600 | } 1601 | [type=range]::-moz-range-thumb { 1602 | -webkit-appearance: none; 1603 | width: 1.25rem; 1604 | height: 1.25rem; 1605 | margin-top: -0.5rem; 1606 | border: 2px solid var(--range-thumb-border-color); 1607 | border-radius: 50%; 1608 | background-color: var(--range-thumb-color); 1609 | cursor: pointer; 1610 | -moz-transition: background-color var(--transition), transform var(--transition); 1611 | transition: background-color var(--transition), transform var(--transition); 1612 | } 1613 | [type=range]::-ms-thumb { 1614 | -webkit-appearance: none; 1615 | width: 1.25rem; 1616 | height: 1.25rem; 1617 | margin-top: -0.5rem; 1618 | border: 2px solid var(--range-thumb-border-color); 1619 | border-radius: 50%; 1620 | background-color: var(--range-thumb-color); 1621 | cursor: pointer; 1622 | -ms-transition: background-color var(--transition), transform var(--transition); 1623 | transition: background-color var(--transition), transform var(--transition); 1624 | } 1625 | [type=range]:hover, [type=range]:focus { 1626 | --range-border-color: var(--range-active-border-color); 1627 | --range-thumb-color: var(--range-thumb-hover-color); 1628 | } 1629 | [type=range]:active { 1630 | --range-thumb-color: var(--range-thumb-active-color); 1631 | } 1632 | [type=range]:active::-webkit-slider-thumb { 1633 | transform: scale(1.25); 1634 | } 1635 | [type=range]:active::-moz-range-thumb { 1636 | transform: scale(1.25); 1637 | } 1638 | [type=range]:active::-ms-thumb { 1639 | transform: scale(1.25); 1640 | } 1641 | 1642 | input:not([type=checkbox], [type=radio], [type=range], [type=file])[type=search] { 1643 | -webkit-padding-start: calc(var(--form-element-spacing-horizontal) + 1.75rem); 1644 | padding-inline-start: calc(var(--form-element-spacing-horizontal) + 1.75rem); 1645 | border-radius: 5rem; 1646 | background-image: var(--icon-search); 1647 | background-position: center left 1.125rem; 1648 | background-size: 1rem auto; 1649 | background-repeat: no-repeat; 1650 | } 1651 | input:not([type=checkbox], [type=radio], [type=range], [type=file])[type=search][aria-invalid] { 1652 | -webkit-padding-start: calc(var(--form-element-spacing-horizontal) + 1.75rem) !important; 1653 | padding-inline-start: calc(var(--form-element-spacing-horizontal) + 1.75rem) !important; 1654 | background-position: center left 1.125rem, center right 0.75rem; 1655 | } 1656 | input:not([type=checkbox], [type=radio], [type=range], [type=file])[type=search][aria-invalid=false] { 1657 | background-image: var(--icon-search), var(--icon-valid); 1658 | } 1659 | input:not([type=checkbox], [type=radio], [type=range], [type=file])[type=search][aria-invalid=true] { 1660 | background-image: var(--icon-search), var(--icon-invalid); 1661 | } 1662 | 1663 | [type=search]::-webkit-search-cancel-button { 1664 | -webkit-appearance: none; 1665 | display: none; 1666 | } 1667 | 1668 | [dir=rtl] :where(input):not([type=checkbox], [type=radio], [type=range], [type=file])[type=search] { 1669 | background-position: center right 1.125rem; 1670 | } 1671 | [dir=rtl] :where(input):not([type=checkbox], [type=radio], [type=range], [type=file])[type=search][aria-invalid] { 1672 | background-position: center right 1.125rem, center left 0.75rem; 1673 | } 1674 | 1675 | /** 1676 | * Table 1677 | */ 1678 | :where(table) { 1679 | width: 100%; 1680 | border-collapse: collapse; 1681 | border-spacing: 0; 1682 | text-indent: 0; 1683 | } 1684 | 1685 | th, 1686 | td { 1687 | padding: calc(var(--spacing) / 2) var(--spacing); 1688 | border-bottom: var(--border-width) solid var(--table-border-color); 1689 | color: var(--color); 1690 | font-weight: var(--font-weight); 1691 | font-size: var(--font-size); 1692 | text-align: left; 1693 | text-align: start; 1694 | } 1695 | 1696 | tfoot th, 1697 | tfoot td { 1698 | border-top: var(--border-width) solid var(--table-border-color); 1699 | border-bottom: 0; 1700 | } 1701 | 1702 | table[role=grid] tbody tr:nth-child(odd) { 1703 | background-color: var(--table-row-stripped-background-color); 1704 | } 1705 | 1706 | /** 1707 | * Code 1708 | */ 1709 | pre, 1710 | code, 1711 | kbd, 1712 | samp { 1713 | font-size: 0.875em; 1714 | font-family: var(--font-family); 1715 | } 1716 | 1717 | pre { 1718 | -ms-overflow-style: scrollbar; 1719 | overflow: auto; 1720 | } 1721 | 1722 | pre, 1723 | code, 1724 | kbd { 1725 | border-radius: var(--border-radius); 1726 | background: var(--code-background-color); 1727 | color: var(--code-color); 1728 | font-weight: var(--font-weight); 1729 | line-height: initial; 1730 | } 1731 | 1732 | code, 1733 | kbd { 1734 | display: inline-block; 1735 | padding: 0.375rem 0.5rem; 1736 | } 1737 | 1738 | pre { 1739 | display: block; 1740 | margin-bottom: var(--spacing); 1741 | overflow-x: auto; 1742 | } 1743 | pre > code { 1744 | display: block; 1745 | padding: var(--spacing); 1746 | background: none; 1747 | font-size: 14px; 1748 | line-height: var(--line-height); 1749 | } 1750 | 1751 | code b { 1752 | color: var(--code-tag-color); 1753 | font-weight: var(--font-weight); 1754 | } 1755 | code i { 1756 | color: var(--code-property-color); 1757 | font-style: normal; 1758 | } 1759 | code u { 1760 | color: var(--code-value-color); 1761 | text-decoration: none; 1762 | } 1763 | code em { 1764 | color: var(--code-comment-color); 1765 | font-style: normal; 1766 | } 1767 | 1768 | kbd { 1769 | background-color: var(--code-kbd-background-color); 1770 | color: var(--code-kbd-color); 1771 | vertical-align: baseline; 1772 | } 1773 | 1774 | /** 1775 | * Miscs 1776 | */ 1777 | hr { 1778 | height: 0; 1779 | border: 0; 1780 | border-top: 1px solid var(--muted-border-color); 1781 | color: inherit; 1782 | } 1783 | 1784 | [hidden], 1785 | template { 1786 | display: none !important; 1787 | } 1788 | 1789 | canvas { 1790 | display: inline-block; 1791 | } 1792 | 1793 | /** 1794 | * Accordion (
) 1795 | */ 1796 | details { 1797 | display: block; 1798 | margin-bottom: var(--spacing); 1799 | padding-bottom: var(--spacing); 1800 | border-bottom: var(--border-width) solid var(--accordion-border-color); 1801 | } 1802 | details summary { 1803 | line-height: 1rem; 1804 | list-style-type: none; 1805 | cursor: pointer; 1806 | transition: color var(--transition); 1807 | } 1808 | details summary:not([role]) { 1809 | color: var(--accordion-close-summary-color); 1810 | } 1811 | details summary::-webkit-details-marker { 1812 | display: none; 1813 | } 1814 | details summary::marker { 1815 | display: none; 1816 | } 1817 | details summary::-moz-list-bullet { 1818 | list-style-type: none; 1819 | } 1820 | details summary::after { 1821 | display: block; 1822 | width: 1rem; 1823 | height: 1rem; 1824 | -webkit-margin-start: calc(var(--spacing, 1rem) * 0.5); 1825 | margin-inline-start: calc(var(--spacing, 1rem) * 0.5); 1826 | float: right; 1827 | transform: rotate(-90deg); 1828 | background-image: var(--icon-chevron); 1829 | background-position: right center; 1830 | background-size: 1rem auto; 1831 | background-repeat: no-repeat; 1832 | content: ""; 1833 | transition: transform var(--transition); 1834 | } 1835 | details summary:focus { 1836 | outline: none; 1837 | } 1838 | details summary:focus:not([role=button]) { 1839 | color: var(--accordion-active-summary-color); 1840 | } 1841 | details summary[role=button] { 1842 | width: 100%; 1843 | text-align: left; 1844 | } 1845 | details summary[role=button]::after { 1846 | height: calc(1rem * var(--line-height, 1.5)); 1847 | background-image: var(--icon-chevron-button); 1848 | } 1849 | details summary[role=button]:not(.outline).contrast::after { 1850 | background-image: var(--icon-chevron-button-inverse); 1851 | } 1852 | details[open] > summary { 1853 | margin-bottom: calc(var(--spacing)); 1854 | } 1855 | details[open] > summary:not([role]):not(:focus) { 1856 | color: var(--accordion-open-summary-color); 1857 | } 1858 | details[open] > summary::after { 1859 | transform: rotate(0); 1860 | } 1861 | 1862 | [dir=rtl] details summary { 1863 | text-align: right; 1864 | } 1865 | [dir=rtl] details summary::after { 1866 | float: left; 1867 | background-position: left center; 1868 | } 1869 | 1870 | /** 1871 | * Card (
) 1872 | */ 1873 | article { 1874 | margin: var(--block-spacing-vertical) 0; 1875 | padding: var(--block-spacing-vertical) var(--block-spacing-horizontal); 1876 | border-radius: var(--border-radius); 1877 | background: var(--card-background-color); 1878 | box-shadow: var(--card-box-shadow); 1879 | } 1880 | article > header, 1881 | article > footer { 1882 | margin-right: calc(var(--block-spacing-horizontal) * -1); 1883 | margin-left: calc(var(--block-spacing-horizontal) * -1); 1884 | padding: calc(var(--block-spacing-vertical) * 0.66) var(--block-spacing-horizontal); 1885 | background-color: var(--card-sectionning-background-color); 1886 | } 1887 | article > header { 1888 | margin-top: calc(var(--block-spacing-vertical) * -1); 1889 | margin-bottom: var(--block-spacing-vertical); 1890 | border-bottom: var(--border-width) solid var(--card-border-color); 1891 | border-top-right-radius: var(--border-radius); 1892 | border-top-left-radius: var(--border-radius); 1893 | } 1894 | article > footer { 1895 | margin-top: var(--block-spacing-vertical); 1896 | margin-bottom: calc(var(--block-spacing-vertical) * -1); 1897 | border-top: var(--border-width) solid var(--card-border-color); 1898 | border-bottom-right-radius: var(--border-radius); 1899 | border-bottom-left-radius: var(--border-radius); 1900 | } 1901 | 1902 | /** 1903 | * Modal () 1904 | */ 1905 | :root { 1906 | --scrollbar-width: 0px; 1907 | } 1908 | 1909 | dialog { 1910 | display: flex; 1911 | z-index: 999; 1912 | position: fixed; 1913 | top: 0; 1914 | right: 0; 1915 | bottom: 0; 1916 | left: 0; 1917 | align-items: center; 1918 | justify-content: center; 1919 | width: inherit; 1920 | min-width: 100%; 1921 | height: inherit; 1922 | min-height: 100%; 1923 | padding: var(--spacing); 1924 | border: 0; 1925 | -webkit-backdrop-filter: var(--modal-overlay-backdrop-filter); 1926 | backdrop-filter: var(--modal-overlay-backdrop-filter); 1927 | background-color: var(--modal-overlay-background-color); 1928 | color: var(--color); 1929 | } 1930 | dialog article { 1931 | max-height: calc(100vh - var(--spacing) * 2); 1932 | overflow: auto; 1933 | } 1934 | @media (min-width: 576px) { 1935 | dialog article { 1936 | max-width: 510px; 1937 | } 1938 | } 1939 | @media (min-width: 768px) { 1940 | dialog article { 1941 | max-width: 700px; 1942 | } 1943 | } 1944 | dialog article > header, 1945 | dialog article > footer { 1946 | padding: calc(var(--block-spacing-vertical) * 0.5) var(--block-spacing-horizontal); 1947 | } 1948 | dialog article > header .close { 1949 | margin: 0; 1950 | margin-left: var(--spacing); 1951 | float: right; 1952 | } 1953 | dialog article > footer { 1954 | text-align: right; 1955 | } 1956 | dialog article > footer [role=button] { 1957 | margin-bottom: 0; 1958 | } 1959 | dialog article > footer [role=button]:not(:first-of-type) { 1960 | margin-left: calc(var(--spacing) * 0.5); 1961 | } 1962 | dialog article p:last-of-type { 1963 | margin: 0; 1964 | } 1965 | dialog article .close { 1966 | display: block; 1967 | width: 1rem; 1968 | height: 1rem; 1969 | margin-top: calc(var(--block-spacing-vertical) * -0.5); 1970 | margin-bottom: var(--typography-spacing-vertical); 1971 | margin-left: auto; 1972 | background-image: var(--icon-close); 1973 | background-position: center; 1974 | background-size: auto 1rem; 1975 | background-repeat: no-repeat; 1976 | opacity: 0.5; 1977 | transition: opacity var(--transition); 1978 | } 1979 | dialog article .close:is([aria-current], :hover, :active, :focus) { 1980 | opacity: 1; 1981 | } 1982 | dialog:not([open]), dialog[open=false] { 1983 | display: none; 1984 | } 1985 | 1986 | .modal-is-open { 1987 | padding-right: var(--scrollbar-width, 0px); 1988 | overflow: hidden; 1989 | pointer-events: none; 1990 | touch-action: none; 1991 | } 1992 | .modal-is-open dialog { 1993 | pointer-events: auto; 1994 | } 1995 | 1996 | :where(.modal-is-opening, .modal-is-closing) dialog, 1997 | :where(.modal-is-opening, .modal-is-closing) dialog > article { 1998 | animation-duration: 0.2s; 1999 | animation-timing-function: ease-in-out; 2000 | animation-fill-mode: both; 2001 | } 2002 | :where(.modal-is-opening, .modal-is-closing) dialog { 2003 | animation-duration: 0.8s; 2004 | animation-name: modal-overlay; 2005 | } 2006 | :where(.modal-is-opening, .modal-is-closing) dialog > article { 2007 | animation-delay: 0.2s; 2008 | animation-name: modal; 2009 | } 2010 | 2011 | .modal-is-closing dialog, 2012 | .modal-is-closing dialog > article { 2013 | animation-delay: 0s; 2014 | animation-direction: reverse; 2015 | } 2016 | 2017 | @keyframes modal-overlay { 2018 | from { 2019 | -webkit-backdrop-filter: none; 2020 | backdrop-filter: none; 2021 | background-color: transparent; 2022 | } 2023 | } 2024 | @keyframes modal { 2025 | from { 2026 | transform: translateY(-100%); 2027 | opacity: 0; 2028 | } 2029 | } 2030 | /** 2031 | * Nav 2032 | */ 2033 | :where(nav li)::before { 2034 | float: left; 2035 | content: "​"; 2036 | } 2037 | 2038 | nav, 2039 | nav ul { 2040 | display: flex; 2041 | } 2042 | 2043 | nav { 2044 | justify-content: space-between; 2045 | } 2046 | nav ol, 2047 | nav ul { 2048 | align-items: center; 2049 | margin-bottom: 0; 2050 | padding: 0; 2051 | list-style: none; 2052 | } 2053 | nav ol:first-of-type, 2054 | nav ul:first-of-type { 2055 | margin-left: calc(var(--nav-element-spacing-horizontal) * -1); 2056 | } 2057 | nav ol:last-of-type, 2058 | nav ul:last-of-type { 2059 | margin-right: calc(var(--nav-element-spacing-horizontal) * -1); 2060 | } 2061 | nav li { 2062 | display: inline-block; 2063 | margin: 0; 2064 | padding: var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal); 2065 | } 2066 | nav li > * { 2067 | --spacing: 0; 2068 | } 2069 | nav :where(a, [role=link]) { 2070 | display: inline-block; 2071 | margin: calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1); 2072 | padding: var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal); 2073 | border-radius: var(--border-radius); 2074 | text-decoration: none; 2075 | } 2076 | nav :where(a, [role=link]):is([aria-current], :hover, :active, :focus) { 2077 | text-decoration: none; 2078 | } 2079 | nav[aria-label=breadcrumb] { 2080 | align-items: center; 2081 | justify-content: start; 2082 | } 2083 | nav[aria-label=breadcrumb] ul li:not(:first-child) { 2084 | -webkit-margin-start: var(--nav-link-spacing-horizontal); 2085 | margin-inline-start: var(--nav-link-spacing-horizontal); 2086 | } 2087 | nav[aria-label=breadcrumb] ul li:not(:last-child) ::after { 2088 | position: absolute; 2089 | width: calc(var(--nav-link-spacing-horizontal) * 2); 2090 | -webkit-margin-start: calc(var(--nav-link-spacing-horizontal) / 2); 2091 | margin-inline-start: calc(var(--nav-link-spacing-horizontal) / 2); 2092 | content: "/"; 2093 | color: var(--muted-color); 2094 | text-align: center; 2095 | } 2096 | nav[aria-label=breadcrumb] a[aria-current] { 2097 | background-color: transparent; 2098 | color: inherit; 2099 | text-decoration: none; 2100 | pointer-events: none; 2101 | } 2102 | nav [role=button] { 2103 | margin-right: inherit; 2104 | margin-left: inherit; 2105 | padding: var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal); 2106 | } 2107 | 2108 | aside nav, 2109 | aside ol, 2110 | aside ul, 2111 | aside li { 2112 | display: block; 2113 | } 2114 | aside li { 2115 | padding: calc(var(--nav-element-spacing-vertical) * 0.5) var(--nav-element-spacing-horizontal); 2116 | } 2117 | aside li a { 2118 | display: block; 2119 | } 2120 | aside li [role=button] { 2121 | margin: inherit; 2122 | } 2123 | 2124 | [dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after { 2125 | content: "\\"; 2126 | } 2127 | 2128 | /** 2129 | * Progress 2130 | */ 2131 | progress { 2132 | display: inline-block; 2133 | vertical-align: baseline; 2134 | } 2135 | 2136 | progress { 2137 | -webkit-appearance: none; 2138 | -moz-appearance: none; 2139 | display: inline-block; 2140 | appearance: none; 2141 | width: 100%; 2142 | height: 0.5rem; 2143 | margin-bottom: calc(var(--spacing) * 0.5); 2144 | overflow: hidden; 2145 | border: 0; 2146 | border-radius: var(--border-radius); 2147 | background-color: var(--progress-background-color); 2148 | color: var(--progress-color); 2149 | } 2150 | progress::-webkit-progress-bar { 2151 | border-radius: var(--border-radius); 2152 | background: none; 2153 | } 2154 | progress[value]::-webkit-progress-value { 2155 | background-color: var(--progress-color); 2156 | } 2157 | progress::-moz-progress-bar { 2158 | background-color: var(--progress-color); 2159 | } 2160 | @media (prefers-reduced-motion: no-preference) { 2161 | progress:indeterminate { 2162 | background: var(--progress-background-color) linear-gradient(to right, var(--progress-color) 30%, var(--progress-background-color) 30%) top left/150% 150% no-repeat; 2163 | animation: progress-indeterminate 1s linear infinite; 2164 | } 2165 | progress:indeterminate[value]::-webkit-progress-value { 2166 | background-color: transparent; 2167 | } 2168 | progress:indeterminate::-moz-progress-bar { 2169 | background-color: transparent; 2170 | } 2171 | } 2172 | 2173 | @media (prefers-reduced-motion: no-preference) { 2174 | [dir=rtl] progress:indeterminate { 2175 | animation-direction: reverse; 2176 | } 2177 | } 2178 | 2179 | @keyframes progress-indeterminate { 2180 | 0% { 2181 | background-position: 200% 0; 2182 | } 2183 | 100% { 2184 | background-position: -200% 0; 2185 | } 2186 | } 2187 | /** 2188 | * Dropdown ([role="list"]) 2189 | */ 2190 | details[role=list], 2191 | li[role=list] { 2192 | position: relative; 2193 | } 2194 | 2195 | details[role=list] summary + ul, 2196 | li[role=list] > ul { 2197 | display: flex; 2198 | z-index: 99; 2199 | position: absolute; 2200 | top: auto; 2201 | right: 0; 2202 | left: 0; 2203 | flex-direction: column; 2204 | margin: 0; 2205 | padding: 0; 2206 | border: var(--border-width) solid var(--dropdown-border-color); 2207 | border-radius: var(--border-radius); 2208 | border-top-right-radius: 0; 2209 | border-top-left-radius: 0; 2210 | background-color: var(--dropdown-background-color); 2211 | box-shadow: var(--card-box-shadow); 2212 | color: var(--dropdown-color); 2213 | white-space: nowrap; 2214 | } 2215 | details[role=list] summary + ul li, 2216 | li[role=list] > ul li { 2217 | width: 100%; 2218 | margin-bottom: 0; 2219 | padding: calc(var(--form-element-spacing-vertical) * 0.5) var(--form-element-spacing-horizontal); 2220 | list-style: none; 2221 | } 2222 | details[role=list] summary + ul li:first-of-type, 2223 | li[role=list] > ul li:first-of-type { 2224 | margin-top: calc(var(--form-element-spacing-vertical) * 0.5); 2225 | } 2226 | details[role=list] summary + ul li:last-of-type, 2227 | li[role=list] > ul li:last-of-type { 2228 | margin-bottom: calc(var(--form-element-spacing-vertical) * 0.5); 2229 | } 2230 | details[role=list] summary + ul li a, 2231 | li[role=list] > ul li a { 2232 | display: block; 2233 | margin: calc(var(--form-element-spacing-vertical) * -0.5) calc(var(--form-element-spacing-horizontal) * -1); 2234 | padding: calc(var(--form-element-spacing-vertical) * 0.5) var(--form-element-spacing-horizontal); 2235 | overflow: hidden; 2236 | color: var(--dropdown-color); 2237 | text-decoration: none; 2238 | text-overflow: ellipsis; 2239 | } 2240 | details[role=list] summary + ul li a:hover, 2241 | li[role=list] > ul li a:hover { 2242 | background-color: var(--dropdown-hover-background-color); 2243 | } 2244 | 2245 | details[role=list] summary::after, 2246 | li[role=list] > a::after { 2247 | display: block; 2248 | width: 1rem; 2249 | height: calc(1rem * var(--line-height, 1.5)); 2250 | -webkit-margin-start: 0.5rem; 2251 | margin-inline-start: 0.5rem; 2252 | float: right; 2253 | transform: rotate(0deg); 2254 | background-position: right center; 2255 | background-size: 1rem auto; 2256 | background-repeat: no-repeat; 2257 | content: ""; 2258 | } 2259 | 2260 | details[role=list] { 2261 | padding: 0; 2262 | border-bottom: none; 2263 | } 2264 | details[role=list] summary { 2265 | margin-bottom: 0; 2266 | } 2267 | details[role=list] summary:not([role]) { 2268 | height: calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2); 2269 | padding: var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal); 2270 | border: var(--border-width) solid var(--form-element-border-color); 2271 | border-radius: var(--border-radius); 2272 | background-color: var(--form-element-background-color); 2273 | color: var(--form-element-placeholder-color); 2274 | line-height: inherit; 2275 | cursor: pointer; 2276 | transition: background-color var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); 2277 | } 2278 | details[role=list] summary:not([role]):active, details[role=list] summary:not([role]):focus { 2279 | border-color: var(--form-element-active-border-color); 2280 | background-color: var(--form-element-active-background-color); 2281 | } 2282 | details[role=list] summary:not([role]):focus { 2283 | box-shadow: 0 0 0 var(--outline-width) var(--form-element-focus-color); 2284 | } 2285 | details[role=list][open] summary { 2286 | border-bottom-right-radius: 0; 2287 | border-bottom-left-radius: 0; 2288 | } 2289 | details[role=list][open] summary::before { 2290 | display: block; 2291 | z-index: 1; 2292 | position: fixed; 2293 | top: 0; 2294 | right: 0; 2295 | bottom: 0; 2296 | left: 0; 2297 | background: none; 2298 | content: ""; 2299 | cursor: default; 2300 | } 2301 | 2302 | nav details[role=list] summary, 2303 | nav li[role=list] a { 2304 | display: flex; 2305 | direction: ltr; 2306 | } 2307 | 2308 | nav details[role=list] summary + ul, 2309 | nav li[role=list] > ul { 2310 | min-width: -moz-fit-content; 2311 | min-width: fit-content; 2312 | border-radius: var(--border-radius); 2313 | } 2314 | nav details[role=list] summary + ul li a, 2315 | nav li[role=list] > ul li a { 2316 | border-radius: 0; 2317 | } 2318 | 2319 | nav details[role=list] summary, 2320 | nav details[role=list] summary:not([role]) { 2321 | height: auto; 2322 | padding: var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal); 2323 | } 2324 | nav details[role=list][open] summary { 2325 | border-radius: var(--border-radius); 2326 | } 2327 | nav details[role=list] summary + ul { 2328 | margin-top: var(--outline-width); 2329 | -webkit-margin-start: 0; 2330 | margin-inline-start: 0; 2331 | } 2332 | nav details[role=list] summary[role=link] { 2333 | margin-bottom: calc(var(--nav-link-spacing-vertical) * -1); 2334 | line-height: var(--line-height); 2335 | } 2336 | nav details[role=list] summary[role=link] + ul { 2337 | margin-top: calc(var(--nav-link-spacing-vertical) + var(--outline-width)); 2338 | -webkit-margin-start: calc(var(--nav-link-spacing-horizontal) * -1); 2339 | margin-inline-start: calc(var(--nav-link-spacing-horizontal) * -1); 2340 | } 2341 | 2342 | li[role=list]:hover > ul, 2343 | li[role=list] a:active ~ ul, 2344 | li[role=list] a:focus ~ ul { 2345 | display: flex; 2346 | } 2347 | li[role=list] > ul { 2348 | display: none; 2349 | margin-top: calc(var(--nav-link-spacing-vertical) + var(--outline-width)); 2350 | -webkit-margin-start: calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal)); 2351 | margin-inline-start: calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal)); 2352 | } 2353 | li[role=list] > a::after { 2354 | background-image: var(--icon-chevron); 2355 | } 2356 | 2357 | label > details[role=list] { 2358 | margin-top: calc(var(--spacing) * 0.25); 2359 | margin-bottom: var(--spacing); 2360 | } 2361 | 2362 | /** 2363 | * Loading ([aria-busy=true]) 2364 | */ 2365 | [aria-busy=true] { 2366 | cursor: progress; 2367 | } 2368 | 2369 | [aria-busy=true]:not(input, select, textarea, html)::before { 2370 | display: inline-block; 2371 | width: 1em; 2372 | height: 1em; 2373 | border: 0.1875em solid currentColor; 2374 | border-radius: 1em; 2375 | border-right-color: transparent; 2376 | content: ""; 2377 | vertical-align: text-bottom; 2378 | vertical-align: -0.125em; 2379 | animation: spinner 0.75s linear infinite; 2380 | opacity: var(--loading-spinner-opacity); 2381 | } 2382 | [aria-busy=true]:not(input, select, textarea, html):not(:empty)::before { 2383 | margin-right: calc(var(--spacing) * 0.5); 2384 | margin-left: 0; 2385 | -webkit-margin-start: 0; 2386 | margin-inline-start: 0; 2387 | -webkit-margin-end: calc(var(--spacing) * 0.5); 2388 | margin-inline-end: calc(var(--spacing) * 0.5); 2389 | } 2390 | [aria-busy=true]:not(input, select, textarea, html):empty { 2391 | text-align: center; 2392 | } 2393 | 2394 | button[aria-busy=true], 2395 | input[type=submit][aria-busy=true], 2396 | input[type=button][aria-busy=true], 2397 | input[type=reset][aria-busy=true], 2398 | a[aria-busy=true] { 2399 | pointer-events: none; 2400 | } 2401 | 2402 | @keyframes spinner { 2403 | to { 2404 | transform: rotate(360deg); 2405 | } 2406 | } 2407 | /** 2408 | * Tooltip ([data-tooltip]) 2409 | */ 2410 | [data-tooltip] { 2411 | position: relative; 2412 | } 2413 | [data-tooltip]:not(a, button, input) { 2414 | border-bottom: 1px dotted; 2415 | text-decoration: none; 2416 | cursor: help; 2417 | } 2418 | [data-tooltip][data-placement=top]::before, [data-tooltip][data-placement=top]::after, [data-tooltip]::before, [data-tooltip]::after { 2419 | display: block; 2420 | z-index: 99; 2421 | position: absolute; 2422 | bottom: 100%; 2423 | left: 50%; 2424 | padding: 0.25rem 0.5rem; 2425 | overflow: hidden; 2426 | transform: translate(-50%, -0.25rem); 2427 | border-radius: var(--border-radius); 2428 | background: var(--tooltip-background-color); 2429 | content: attr(data-tooltip); 2430 | color: var(--tooltip-color); 2431 | font-style: normal; 2432 | font-weight: var(--font-weight); 2433 | font-size: 0.875rem; 2434 | text-decoration: none; 2435 | text-overflow: ellipsis; 2436 | white-space: nowrap; 2437 | opacity: 0; 2438 | pointer-events: none; 2439 | } 2440 | [data-tooltip][data-placement=top]::after, [data-tooltip]::after { 2441 | padding: 0; 2442 | transform: translate(-50%, 0rem); 2443 | border-top: 0.3rem solid; 2444 | border-right: 0.3rem solid transparent; 2445 | border-left: 0.3rem solid transparent; 2446 | border-radius: 0; 2447 | background-color: transparent; 2448 | content: ""; 2449 | color: var(--tooltip-background-color); 2450 | } 2451 | [data-tooltip][data-placement=bottom]::before, [data-tooltip][data-placement=bottom]::after { 2452 | top: 100%; 2453 | bottom: auto; 2454 | transform: translate(-50%, 0.25rem); 2455 | } 2456 | [data-tooltip][data-placement=bottom]:after { 2457 | transform: translate(-50%, -0.3rem); 2458 | border: 0.3rem solid transparent; 2459 | border-bottom: 0.3rem solid; 2460 | } 2461 | [data-tooltip][data-placement=left]::before, [data-tooltip][data-placement=left]::after { 2462 | top: 50%; 2463 | right: 100%; 2464 | bottom: auto; 2465 | left: auto; 2466 | transform: translate(-0.25rem, -50%); 2467 | } 2468 | [data-tooltip][data-placement=left]:after { 2469 | transform: translate(0.3rem, -50%); 2470 | border: 0.3rem solid transparent; 2471 | border-left: 0.3rem solid; 2472 | } 2473 | [data-tooltip][data-placement=right]::before, [data-tooltip][data-placement=right]::after { 2474 | top: 50%; 2475 | right: auto; 2476 | bottom: auto; 2477 | left: 100%; 2478 | transform: translate(0.25rem, -50%); 2479 | } 2480 | [data-tooltip][data-placement=right]:after { 2481 | transform: translate(-0.3rem, -50%); 2482 | border: 0.3rem solid transparent; 2483 | border-right: 0.3rem solid; 2484 | } 2485 | [data-tooltip]:focus::before, [data-tooltip]:focus::after, [data-tooltip]:hover::before, [data-tooltip]:hover::after { 2486 | opacity: 1; 2487 | } 2488 | @media (hover: hover) and (pointer: fine) { 2489 | [data-tooltip][data-placement=bottom]:focus::before, [data-tooltip][data-placement=bottom]:focus::after, [data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::before, [data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after, [data-tooltip]:hover::before, [data-tooltip]:hover::after { 2490 | animation-duration: 0.2s; 2491 | animation-name: tooltip-slide-top; 2492 | } 2493 | [data-tooltip][data-placement=bottom]:focus::after, [data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after, [data-tooltip]:hover::after { 2494 | animation-name: tooltip-caret-slide-top; 2495 | } 2496 | [data-tooltip][data-placement=bottom]:focus::before, [data-tooltip][data-placement=bottom]:focus::after, [data-tooltip][data-placement=bottom]:hover::before, [data-tooltip][data-placement=bottom]:hover::after { 2497 | animation-duration: 0.2s; 2498 | animation-name: tooltip-slide-bottom; 2499 | } 2500 | [data-tooltip][data-placement=bottom]:focus::after, [data-tooltip][data-placement=bottom]:hover::after { 2501 | animation-name: tooltip-caret-slide-bottom; 2502 | } 2503 | [data-tooltip][data-placement=left]:focus::before, [data-tooltip][data-placement=left]:focus::after, [data-tooltip][data-placement=left]:hover::before, [data-tooltip][data-placement=left]:hover::after { 2504 | animation-duration: 0.2s; 2505 | animation-name: tooltip-slide-left; 2506 | } 2507 | [data-tooltip][data-placement=left]:focus::after, [data-tooltip][data-placement=left]:hover::after { 2508 | animation-name: tooltip-caret-slide-left; 2509 | } 2510 | [data-tooltip][data-placement=right]:focus::before, [data-tooltip][data-placement=right]:focus::after, [data-tooltip][data-placement=right]:hover::before, [data-tooltip][data-placement=right]:hover::after { 2511 | animation-duration: 0.2s; 2512 | animation-name: tooltip-slide-right; 2513 | } 2514 | [data-tooltip][data-placement=right]:focus::after, [data-tooltip][data-placement=right]:hover::after { 2515 | animation-name: tooltip-caret-slide-right; 2516 | } 2517 | } 2518 | @keyframes tooltip-slide-top { 2519 | from { 2520 | transform: translate(-50%, 0.75rem); 2521 | opacity: 0; 2522 | } 2523 | to { 2524 | transform: translate(-50%, -0.25rem); 2525 | opacity: 1; 2526 | } 2527 | } 2528 | @keyframes tooltip-caret-slide-top { 2529 | from { 2530 | opacity: 0; 2531 | } 2532 | 50% { 2533 | transform: translate(-50%, -0.25rem); 2534 | opacity: 0; 2535 | } 2536 | to { 2537 | transform: translate(-50%, 0rem); 2538 | opacity: 1; 2539 | } 2540 | } 2541 | @keyframes tooltip-slide-bottom { 2542 | from { 2543 | transform: translate(-50%, -0.75rem); 2544 | opacity: 0; 2545 | } 2546 | to { 2547 | transform: translate(-50%, 0.25rem); 2548 | opacity: 1; 2549 | } 2550 | } 2551 | @keyframes tooltip-caret-slide-bottom { 2552 | from { 2553 | opacity: 0; 2554 | } 2555 | 50% { 2556 | transform: translate(-50%, -0.5rem); 2557 | opacity: 0; 2558 | } 2559 | to { 2560 | transform: translate(-50%, -0.3rem); 2561 | opacity: 1; 2562 | } 2563 | } 2564 | @keyframes tooltip-slide-left { 2565 | from { 2566 | transform: translate(0.75rem, -50%); 2567 | opacity: 0; 2568 | } 2569 | to { 2570 | transform: translate(-0.25rem, -50%); 2571 | opacity: 1; 2572 | } 2573 | } 2574 | @keyframes tooltip-caret-slide-left { 2575 | from { 2576 | opacity: 0; 2577 | } 2578 | 50% { 2579 | transform: translate(0.05rem, -50%); 2580 | opacity: 0; 2581 | } 2582 | to { 2583 | transform: translate(0.3rem, -50%); 2584 | opacity: 1; 2585 | } 2586 | } 2587 | @keyframes tooltip-slide-right { 2588 | from { 2589 | transform: translate(-0.75rem, -50%); 2590 | opacity: 0; 2591 | } 2592 | to { 2593 | transform: translate(0.25rem, -50%); 2594 | opacity: 1; 2595 | } 2596 | } 2597 | @keyframes tooltip-caret-slide-right { 2598 | from { 2599 | opacity: 0; 2600 | } 2601 | 50% { 2602 | transform: translate(-0.05rem, -50%); 2603 | opacity: 0; 2604 | } 2605 | to { 2606 | transform: translate(-0.3rem, -50%); 2607 | opacity: 1; 2608 | } 2609 | } 2610 | 2611 | /** 2612 | * Accessibility & User interaction 2613 | */ 2614 | [aria-controls] { 2615 | cursor: pointer; 2616 | } 2617 | 2618 | [aria-disabled=true], 2619 | [disabled] { 2620 | cursor: not-allowed; 2621 | } 2622 | 2623 | [aria-hidden=false][hidden] { 2624 | display: initial; 2625 | } 2626 | 2627 | [aria-hidden=false][hidden]:not(:focus) { 2628 | clip: rect(0, 0, 0, 0); 2629 | position: absolute; 2630 | } 2631 | 2632 | a, 2633 | area, 2634 | button, 2635 | input, 2636 | label, 2637 | select, 2638 | summary, 2639 | textarea, 2640 | [tabindex] { 2641 | -ms-touch-action: manipulation; 2642 | } 2643 | 2644 | [dir=rtl] { 2645 | direction: rtl; 2646 | } 2647 | 2648 | /** 2649 | * Reduce Motion Features 2650 | */ 2651 | @media (prefers-reduced-motion: reduce) { 2652 | *:not([aria-busy=true]), 2653 | :not([aria-busy=true])::before, 2654 | :not([aria-busy=true])::after { 2655 | background-attachment: initial !important; 2656 | animation-duration: 1ms !important; 2657 | animation-delay: -1ms !important; 2658 | animation-iteration-count: 1 !important; 2659 | scroll-behavior: auto !important; 2660 | transition-delay: 0s !important; 2661 | transition-duration: 0s !important; 2662 | } 2663 | } 2664 | --------------------------------------------------------------------------------