diff --git a/Gemfile b/Gemfile
index cfa068b..d3cc81c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,6 @@ source 'https://rubygems.org'
ruby file: '.ruby-version'
gem 'bootsnap', require: false
-gem 'bootstrap', '~> 5.3'
gem 'castle-rb', '~> 8.1'
gem 'devise', '~> 5.0'
gem 'dotenv-rails'
@@ -15,10 +14,10 @@ gem 'omniauth-twitter'
gem 'puma', '~> 6.4'
gem 'rails', '~> 8.1.3'
gem 'responders'
-gem 'sassc-rails'
gem 'simple_form'
gem 'sprockets-rails'
gem 'sqlite3', '~> 2.1'
+gem 'tailwindcss-rails', '~> 3.3'
group :development, :test do
gem 'byebug'
diff --git a/Gemfile.lock b/Gemfile.lock
index 15795b0..67e6933 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -83,8 +83,6 @@ GEM
bindex (0.8.1)
bootsnap (1.24.6)
msgpack (~> 1.2)
- bootstrap (5.3.8)
- popper_js (>= 2.11.8, < 3)
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.0)
@@ -116,8 +114,6 @@ GEM
railties (>= 6.1.0)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
- ffi (1.17.4)
- ffi (1.17.4-arm64-darwin)
globalid (1.3.0)
activesupport (>= 6.1)
hamlit (4.0.0)
@@ -170,8 +166,12 @@ GEM
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
+ nokogiri (1.19.3-aarch64-linux-gnu)
+ racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
+ nokogiri (1.19.3-x86_64-linux-gnu)
+ racc (~> 1.4)
oauth (1.1.5)
auth-sanitizer (~> 0.1, >= 0.1.3)
base64 (~> 0.1)
@@ -199,7 +199,6 @@ GEM
omniauth-oauth (~> 1.1)
rack
orm_adapter (0.5.0)
- popper_js (2.11.8)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
@@ -283,14 +282,6 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.7)
- sassc (2.4.0)
- ffi (~> 1.9)
- sassc-rails (2.1.2)
- railties (>= 4.0.0)
- sassc (>= 2.0)
- sprockets (> 3.0)
- sprockets-rails
- tilt
securerandom (0.4.1)
simple_form (5.4.1)
actionpack (>= 7.0)
@@ -314,8 +305,17 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (2.9.4)
mini_portile2 (~> 2.8.0)
+ sqlite3 (2.9.4-aarch64-linux-gnu)
sqlite3 (2.9.4-arm64-darwin)
+ sqlite3 (2.9.4-x86_64-linux-gnu)
stringio (3.2.0)
+ tailwindcss-rails (3.3.2)
+ railties (>= 7.0.0)
+ tailwindcss-ruby (~> 3.0)
+ tailwindcss-ruby (3.4.19)
+ tailwindcss-ruby (3.4.19-aarch64-linux)
+ tailwindcss-ruby (3.4.19-arm64-darwin)
+ tailwindcss-ruby (3.4.19-x86_64-linux)
temple (0.10.4)
thor (1.5.0)
tilt (2.7.0)
@@ -340,12 +340,13 @@ GEM
zeitwerk (2.8.2)
PLATFORMS
+ aarch64-linux
arm64-darwin-25
ruby
+ x86_64-linux
DEPENDENCIES
bootsnap
- bootstrap (~> 5.3)
byebug
castle-rb (~> 8.1)
devise (~> 5.0)
@@ -360,11 +361,11 @@ DEPENDENCIES
rails-controller-testing
responders
rspec-rails
- sassc-rails
simple_form
simplecov
sprockets-rails
sqlite3 (~> 2.1)
+ tailwindcss-rails (~> 3.3)
web-console
RUBY VERSION
diff --git a/README.md b/README.md
index ec18935..61018a9 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,12 @@ SDK (8.x).
- **browser SDK** – the `@castleio/castle-js` SDK mints a request token in the
browser that is submitted with the login form and forwarded to the API.
+## Screenshots
+
+| Home | Login |
+| ---- | ----- |
+|  |  |
+
## Prerequisites
You'll need a Castle account. If you don't have one, start a free trial at
@@ -55,6 +61,25 @@ bin/rails server
`bin/setup` runs the dependency install, file copying and database setup in one
step.
+## Styling (Tailwind CSS)
+
+The UI is styled with [Tailwind CSS](https://tailwindcss.com) via the
+[`tailwindcss-rails`](https://github.com/rails/tailwindcss-rails) gem (no Node
+toolchain required). The source is `app/assets/stylesheets/application.tailwind.css`
+with design tokens in `config/tailwind.config.js`; it compiles to
+`app/assets/builds/tailwind.css`, which is committed so `bin/rails server` works
+without a build step.
+
+If you change the views or the Tailwind source, regenerate the stylesheet:
+
+```bash
+bin/rails tailwindcss:build # one-off build
+bin/rails tailwindcss:watch # rebuild on change during development
+```
+
+`assets:precompile` (used by the Docker build) runs `tailwindcss:build`
+automatically.
+
## Configuration
All configuration is read from environment variables (loaded from `.env` in
diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css
new file mode 100644
index 0000000..4c7a143
--- /dev/null
+++ b/app/assets/builds/tailwind.css
@@ -0,0 +1 @@
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{min-height:100vh;--tw-bg-opacity:1;background-color:rgb(11 14 20/var(--tw-bg-opacity,1));color:rgb(230 233 239/var(--tw-text-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(124,92,255,.12),transparent 60%)}a,body{--tw-text-opacity:1}a{color:rgb(124 92 255/var(--tw-text-opacity,1));text-decoration-line:none}a:hover{text-decoration-line:underline}h1,h2,h3,h4{font-weight:600;line-height:1.25}p{margin-bottom:.75rem}code{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(27 34 48/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;padding:.125rem .375rem}.relative{position:relative}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.table{display:table}.hidden{display:none}.w-full{width:100%}.list-disc{list-style-type:disc}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.border{border-width:1px}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.text-\[1\.2rem\]{font-size:1.2rem}.text-\[1\.3rem\]{font-size:1.3rem}.text-\[1\.4rem\]{font-size:1.4rem}.text-\[2rem\]{font-size:2rem}.text-muted{--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.navbar{border-bottom-width:1px;flex-wrap:wrap;gap:1.5rem;position:sticky;top:0;z-index:50;--tw-border-opacity:1;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:rgba(13,16,22,.8);border-color:rgb(35 43 57/var(--tw-border-opacity,1));padding:.875rem 1.5rem}.brand,.navbar{align-items:center;display:flex}.brand{font-size:1.05rem;font-weight:700;gap:.5rem;--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-dot{border-radius:9999px;height:.625rem;width:.625rem;--tw-bg-opacity:1;background-color:rgb(124 92 255/var(--tw-bg-opacity,1));box-shadow:0 0 12px #7c5cff}.nav-links{align-items:center;display:flex;flex-wrap:wrap;gap:1.25rem;margin-left:auto}.nav-links a{font-size:.92rem;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.nav-links a:hover{--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1));text-decoration-line:none}.tag{background-color:rgba(124,92,255,.1);border-color:rgba(124,92,255,.4);border-radius:9999px;border-width:1px;font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(124 92 255/var(--tw-text-opacity,1))}.container-page{margin-left:auto;margin-right:auto;max-width:1120px;padding:2rem 1.5rem 4rem}.container-narrow{margin-left:auto;margin-right:auto;max-width:420px;padding:4rem 1.5rem;width:100%}.card{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(21 26 35/var(--tw-bg-opacity,1));padding:1.5rem;--tw-shadow:0 10px 30px rgba(0,0,0,.35);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.05em;line-height:1rem;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.hero{margin-bottom:2rem;padding:2rem}.feature,.hero{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(21 26 35/var(--tw-bg-opacity,1))}.feature{display:block;padding:1.25rem;text-align:left;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.feature:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-border-opacity:1;border-color:rgb(124 92 255/var(--tw-border-opacity,1));text-decoration-line:none}.section-head{border-bottom-width:1px;margin-bottom:.75rem;margin-top:2rem;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));padding-bottom:.5rem}.prose-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.field{margin-bottom:.875rem}.field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.375rem;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.input,input[type=email],input[type=password],input[type=text]{border-radius:9px;border-width:1px;width:100%;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(17 21 31/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;padding:.625rem .75rem;--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1));transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus{--tw-border-opacity:1;border-color:rgb(124 92 255/var(--tw-border-opacity,1));box-shadow:0 0 0 3px rgba(124,92,255,.14);outline:2px solid transparent;outline-offset:2px}.btn{border-radius:9px;border-width:1px;cursor:pointer;display:inline-block;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(27 34 48/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;padding:.625rem 1rem;text-align:center;--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1));text-decoration-line:none;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(124 92 255/var(--tw-border-opacity,1));text-decoration-line:none}.btn:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary{--tw-border-opacity:1;border-color:rgb(124 92 255/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(124 92 255/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(107 76 240/var(--tw-bg-opacity,1))}.btn-alt,.btn-ghost{background-color:transparent}.btn-danger{background-color:rgba(255,92,124,.1);border-color:rgba(255,92,124,.5);--tw-text-opacity:1;color:rgb(255 92 124/var(--tw-text-opacity,1))}.btn-row{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.alert{align-items:center;border-radius:9px;border-width:1px;display:flex;font-size:.92rem;gap:1rem;justify-content:space-between;margin-bottom:1rem;padding:.75rem 1rem}.alert-success{background-color:rgba(46,204,113,.1);border-color:rgba(46,204,113,.4)}.alert-danger,.alert-success{--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1))}.alert-danger{background-color:rgba(255,92,124,.1);border-color:rgba(255,92,124,.4)}.alert .btn-close{background-color:transparent;border-width:0;cursor:pointer;font-size:1.125rem;line-height:1.75rem;line-height:1;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.alert .btn-close:hover{--tw-text-opacity:1;color:rgb(230 233 239/var(--tw-text-opacity,1))}table.table{border-collapse:collapse;border-radius:9px;font-size:.9rem;overflow:hidden;width:100%}table.table th{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(35 43 57/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(27 34 48/var(--tw-bg-opacity,1));font-size:.78rem;font-weight:700;letter-spacing:.025em;padding:.5rem .75rem;text-align:left;text-transform:uppercase;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}table.table td{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(28 35 48/var(--tw-border-opacity,1));padding:.5rem .75rem;vertical-align:top}.lead{font-size:1.1rem;--tw-text-opacity:1;color:rgb(154 164 178/var(--tw-text-opacity,1))}.form-actions{margin-top:1.25rem}.error,.invalid-feedback{color:rgb(255 92 124/var(--tw-text-opacity,1))}.error,.hint,.invalid-feedback{display:block;font-size:.8rem;margin-top:.25rem;--tw-text-opacity:1}.hint{color:rgb(154 164 178/var(--tw-text-opacity,1))}.field_with_errors{display:contents}
\ No newline at end of file
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index b16e53d..57b152e 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,3 +1,3 @@
//= link_tree ../images
//= link_directory ../javascripts .js
-//= link_directory ../stylesheets .css
+//= link_tree ../builds
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 303dc79..f46e2a9 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -11,6 +11,4 @@
// about supported directives.
//
//= require rails-ujs
-//= require popper
-//= require bootstrap
//= require_tree .
diff --git a/app/assets/javascripts/ui.js b/app/assets/javascripts/ui.js
new file mode 100644
index 0000000..74212b4
--- /dev/null
+++ b/app/assets/javascripts/ui.js
@@ -0,0 +1,20 @@
+// Minimal UI behaviour that previously came from Bootstrap's JS bundle:
+// the responsive navbar toggle and dismissible flash messages.
+(function () {
+ document.addEventListener("DOMContentLoaded", function () {
+ var toggler = document.querySelector("[data-nav-toggle]");
+ var menu = document.querySelector("[data-nav-menu]");
+ if (toggler && menu) {
+ toggler.addEventListener("click", function () {
+ menu.classList.toggle("hidden");
+ });
+ }
+
+ document.querySelectorAll("[data-dismiss-alert]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ var alert = btn.closest(".alert");
+ if (alert) alert.remove();
+ });
+ });
+ });
+})();
diff --git a/app/assets/stylesheets/1st_load_framework.css.scss b/app/assets/stylesheets/1st_load_framework.css.scss
deleted file mode 100644
index 426d705..0000000
--- a/app/assets/stylesheets/1st_load_framework.css.scss
+++ /dev/null
@@ -1,40 +0,0 @@
-@import 'bootstrap';
-
-img {
- @extend .img-fluid;
- margin: 0 auto;
-}
-
-.navbar-brand {
- font-size: inherit;
-}
-
-.column {
- @extend .col-md-6;
- text-align: center;
-}
-
-.form {
- @extend .col-md-6;
-}
-
-.form-centered {
- @extend .col-md-6;
- text-align: center;
-}
-
-.submit {
- @extend .btn;
- @extend .btn-primary;
- @extend .btn-lg;
-}
-
-main {
- @extend .container;
- margin-top: 30px;
-}
-
-section {
- @extend .row;
- margin-top: 20px;
-}
diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss
deleted file mode 100644
index d05ea0f..0000000
--- a/app/assets/stylesheets/application.css.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * This is a manifest file that'll be compiled into application.css, which will include all the files
- * listed below.
- *
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
- * vendor/assets/stylesheets directory can be referenced here using a relative path.
- *
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
- * files in this directory. Styles in this file should be added after the last require_* statement.
- * It is generally better to create a new file per style scope.
- *
- *= require_tree .
- *= require_self
- */
diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css
new file mode 100644
index 0000000..cc3b33a
--- /dev/null
+++ b/app/assets/stylesheets/application.tailwind.css
@@ -0,0 +1,194 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply min-h-screen bg-bg font-sans text-[15px] leading-relaxed text-ink antialiased;
+ background-image: radial-gradient(
+ 1200px 600px at 80% -10%,
+ rgba(124, 92, 255, 0.12),
+ transparent 60%
+ );
+ }
+
+ a {
+ @apply text-accent no-underline hover:underline;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4 {
+ @apply font-semibold leading-tight;
+ }
+
+ p {
+ @apply mb-3;
+ }
+
+ code {
+ @apply rounded border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[0.86em];
+ }
+}
+
+/*
+ * Shared design system component classes (kept in sync with the Node and Python
+ * Castle example apps). Authored outside @layer so they are always emitted.
+ */
+
+.navbar {
+ @apply sticky top-0 z-50 flex flex-wrap items-center gap-6 border-b border-border px-6 py-3.5;
+ background: rgba(13, 16, 22, 0.8);
+ backdrop-filter: blur(10px);
+}
+
+.brand {
+ @apply flex items-center gap-2 text-[1.05rem] font-bold text-ink hover:no-underline;
+}
+
+.brand-dot {
+ @apply h-2.5 w-2.5 rounded-full bg-accent;
+ box-shadow: 0 0 12px #7c5cff;
+}
+
+.nav-links {
+ @apply ml-auto flex flex-wrap items-center gap-5;
+}
+
+.nav-links a {
+ @apply text-[0.92rem] text-muted hover:text-ink hover:no-underline;
+}
+
+.tag {
+ @apply rounded-full border border-accent/40 bg-accent/10 px-2 py-0.5 text-xs font-semibold text-accent;
+}
+
+.container-page {
+ @apply mx-auto max-w-[1120px] px-6 pb-16 pt-8;
+}
+
+.container-narrow {
+ @apply mx-auto w-full max-w-[420px] px-6 py-16;
+}
+
+.card {
+ @apply rounded-xl border border-border bg-surface p-6 shadow-card;
+}
+
+.eyebrow {
+ @apply mb-1.5 text-xs font-bold uppercase tracking-wider text-muted;
+}
+
+.hero {
+ @apply mb-8 rounded-xl border border-border bg-surface p-8;
+}
+
+.feature {
+ @apply block rounded-xl border border-border bg-surface p-5 text-left transition hover:-translate-y-0.5 hover:border-accent hover:no-underline;
+}
+
+.section-head {
+ @apply mb-3 mt-8 border-b border-border pb-2;
+}
+
+.prose-list {
+ @apply space-y-2;
+}
+
+.field {
+ @apply mb-3.5;
+}
+
+.field label {
+ @apply mb-1.5 block text-[0.82rem] font-semibold text-muted;
+}
+
+.input,
+input[type='text'],
+input[type='email'],
+input[type='password'] {
+ @apply w-full rounded-lg border border-border bg-bg-soft px-3 py-2.5 font-sans text-[0.95rem] text-ink transition;
+}
+
+.input:focus,
+input[type='text']:focus,
+input[type='email']:focus,
+input[type='password']:focus {
+ @apply border-accent outline-none;
+ box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.14);
+}
+
+.btn {
+ @apply inline-block cursor-pointer rounded-lg border border-border bg-surface-2 px-4 py-2.5 text-center font-sans text-[0.92rem] font-semibold text-ink no-underline transition hover:border-accent hover:no-underline active:translate-y-px;
+}
+
+.btn-primary {
+ @apply border-accent bg-accent text-white hover:bg-accent-hover;
+}
+
+.btn-ghost,
+.btn-alt {
+ @apply bg-transparent;
+}
+
+.btn-danger {
+ @apply border-danger/50 bg-danger/10 text-danger;
+}
+
+.btn-row {
+ @apply mt-4 flex flex-wrap gap-2.5;
+}
+
+/* Flash messages (replacing Bootstrap alerts). */
+.alert {
+ @apply mb-4 flex items-center justify-between gap-4 rounded-lg border px-4 py-3 text-[0.92rem];
+}
+
+.alert-success {
+ @apply border-success/40 bg-success/10 text-ink;
+}
+
+.alert-danger {
+ @apply border-danger/40 bg-danger/10 text-ink;
+}
+
+.alert .btn-close {
+ @apply cursor-pointer border-0 bg-transparent text-lg leading-none text-muted hover:text-ink;
+}
+
+/* Tables (webhooks list). */
+table.table {
+ @apply w-full border-collapse overflow-hidden rounded-lg text-[0.9rem];
+}
+
+table.table th {
+ @apply border-b border-border bg-surface-2 px-3 py-2 text-left text-[0.78rem] font-bold uppercase tracking-wide text-muted;
+}
+
+table.table td {
+ @apply border-b border-border-soft px-3 py-2 align-top;
+}
+
+.lead {
+ @apply text-[1.1rem] text-muted;
+}
+
+.form-actions {
+ @apply mt-5;
+}
+
+/* simple_form error/hint output. */
+.error,
+.invalid-feedback {
+ @apply mt-1 block text-[0.8rem] text-danger;
+}
+
+.hint {
+ @apply mt-1 block text-[0.8rem] text-muted;
+}
+
+/* Rails wraps invalid fields in this div; keep it from breaking layout. */
+.field_with_errors {
+ display: contents;
+}
diff --git a/app/assets/stylesheets/dashboard.css.scss b/app/assets/stylesheets/dashboard.css.scss
deleted file mode 100644
index d20ceae..0000000
--- a/app/assets/stylesheets/dashboard.css.scss
+++ /dev/null
@@ -1,93 +0,0 @@
-body {
- background-color: #f5f5f5;
- font-size: .875rem;
-}
-
-.pull-right {
- float: right;
-}
-
-#logo {
- img { height: 25px; }
-}
-
-.feather {
- height: 16px;
- vertical-align: text-bottom;
- width: 16px;
-}
-
-.sidebar {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: 100; /* Behind the navbar */
- padding: 48px 0 0; /* Height of navbar */
- box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
-}
-
-.sidebar-sticky {
- position: relative;
- top: 0;
- height: calc(100vh - 48px);
- padding-top: .5rem;
- overflow-x: hidden;
- overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
-}
-
-@supports ((position: -webkit-sticky) or (position: sticky)) {
- .sidebar-sticky {
- position: -webkit-sticky;
- position: sticky;
- }
-}
-
-.sidebar .nav-link {
- font-weight: 500;
- color: #333;
-}
-
-.sidebar .nav-link .feather {
- margin-right: 4px;
- color: #999;
-}
-
-.sidebar .nav-link.active {
- color: #007bff;
-}
-
-.sidebar .nav-link:hover .feather,
-.sidebar .nav-link.active .feather {
- color: inherit;
-}
-
-.sidebar-heading {
- font-size: .75rem;
- text-transform: uppercase;
-}
-
-[role="main"] {
- padding-top: 5rem; /* Space for fixed navbar */
-}
-
-.navbar .form-control {
- padding: .75rem 1rem;
- border-width: 0;
- border-radius: 0;
-}
-
-.form-control-dark {
- color: #fff;
- background-color: rgba(255, 255, 255, .1);
- border-color: rgba(255, 255, 255, .1);
-}
-
-.form-control-dark:focus {
- border-color: transparent;
- box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
-}
-
-
-.border-top { border-top: 1px solid #e5e5e5; }
-.border-bottom { border-bottom: 1px solid #e5e5e5; }
diff --git a/app/assets/stylesheets/signin.css.scss b/app/assets/stylesheets/signin.css.scss
deleted file mode 100644
index 08d335f..0000000
--- a/app/assets/stylesheets/signin.css.scss
+++ /dev/null
@@ -1,40 +0,0 @@
-body.signin {
- height: 100%;
- display: -ms-flexbox;
- display: flex;
- -ms-flex-align: center;
- align-items: center;
- padding-top: 40px;
- padding-bottom: 40px;
- background-color: #f5f5f5;
-
- .form-signin {
- width: 100%;
- max-width: 330px;
- padding: 15px;
- margin: auto;
- }
- .form-signin .checkbox {
- font-weight: 400;
- }
- .form-signin .form-control {
- position: relative;
- box-sizing: border-box;
- height: auto;
- padding: 10px;
- font-size: 16px;
- }
- .form-signin .form-control:focus {
- z-index: 2;
- }
- .form-signin input[type="email"] {
- margin-bottom: -1px;
- border-bottom-right-radius: 0;
- border-bottom-left-radius: 0;
- }
- .form-signin input[type="password"] {
- margin-bottom: 10px;
- border-top-left-radius: 0;
- border-top-right-radius: 0;
- }
-}
diff --git a/app/views/integrations/castle_webhooks/index.html.haml b/app/views/integrations/castle_webhooks/index.html.haml
index f2509d1..136db7e 100644
--- a/app/views/integrations/castle_webhooks/index.html.haml
+++ b/app/views/integrations/castle_webhooks/index.html.haml
@@ -1,13 +1,12 @@
-.d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom
- %h2.h2 Received Castle webhooks
+.section-head
+ %h2{ class: 'text-[1.4rem]' } Received Castle webhooks
%p.lead
This page lists 50 most recent webhooks received from Castle.
-%p
- %table.table
- %tr
- %th Webhook local ID
- %th Webhook body
- %th Created at
- = render @castle_webhooks
+%table.table.mt-4
+ %tr
+ %th Webhook local ID
+ %th Webhook body
+ %th Created at
+ = render @castle_webhooks
diff --git a/app/views/layouts/_header.html.haml b/app/views/layouts/_header.html.haml
index c9b4cb0..845bca8 100644
--- a/app/views/layouts/_header.html.haml
+++ b/app/views/layouts/_header.html.haml
@@ -4,6 +4,10 @@
%title= t('shared.title')
- = stylesheet_link_tag 'application', media: 'all'
+ %link{ rel: 'preconnect', href: 'https://fonts.googleapis.com' }
+ %link{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: true }
+ %link{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap' }
+
+ = stylesheet_link_tag 'tailwind', media: 'all'
= javascript_include_tag 'application'
= csrf_meta_tags
diff --git a/app/views/layouts/_messages.html.haml b/app/views/layouts/_messages.html.haml
index 066557b..071a409 100644
--- a/app/views/layouts/_messages.html.haml
+++ b/app/views/layouts/_messages.html.haml
@@ -1,5 +1,6 @@
- flash.each do |name, msg|
- if msg.is_a?(String)
- %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'} alert-dismissible fade show", role: 'alert' }
+ %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'}", role: 'alert' }
= content_tag :span, msg, id: "flash_#{name}"
- %button.btn-close{ type: 'button', 'data-bs-dismiss' => 'alert', 'aria-label' => 'Close' }
+ %button.btn-close{ type: 'button', 'data-dismiss-alert' => true, 'aria-label' => 'Close' }
+ ×
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index f825484..109d6c4 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -3,34 +3,23 @@
= render 'layouts/header'
%body
- %nav.navbar.navbar-expand-lg.fixed-top.bg-dark.shadow-sm{ 'data-bs-theme' => 'dark' }
- .container-fluid
- = link_to image_tag('logo.png', height: 28, alt: 'Castle'), root_path, class: 'navbar-brand', id: 'logo'
+ %nav.navbar
+ = link_to root_path, class: 'brand', id: 'logo' do
+ %span.brand-dot
+ Castle
+ %span.text-muted{ style: 'font-weight:400' } demo
+ .nav-links
+ = link_to t('.nav.webhooks'), integrations_castle_webhooks_path
- %button.navbar-toggler{ type: 'button', 'data-bs-toggle' => 'collapse',
- 'data-bs-target' => '#navbar-nav', 'aria-controls' => 'navbar-nav',
- 'aria-expanded' => 'false', 'aria-label' => 'Toggle navigation' }
- %span.navbar-toggler-icon
+ - if user_signed_in?
+ = link_to t('.nav.edit_password'), edit_user_registration_path
+ = link_to t('.nav.edit_profile'), edit_users_profile_path
+ = link_to t('.nav.sign_out'), destroy_user_session_path, method: :delete
+ - else
+ = link_to t('.nav.login'), new_user_session_path
+ = link_to t('.nav.register'), new_user_registration_path
- #navbar-nav.collapse.navbar-collapse
- %ul.navbar-nav.ms-auto
- %li.nav-item
- = link_to t('.nav.webhooks'), integrations_castle_webhooks_path, class: 'nav-link'
-
- - if user_signed_in?
- %li.nav-item
- = link_to t('.nav.edit_password'), edit_user_registration_path, class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.edit_profile'), edit_users_profile_path, class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.sign_out'), destroy_user_session_path, method: :delete, class: 'nav-link'
- - else
- %li.nav-item
- = link_to t('.nav.login'), new_user_session_path, class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.register'), new_user_registration_path, class: 'nav-link'
-
- %main.container{ role: 'main' }
+ %main.container-page{ role: 'main' }
= render 'layouts/messages'
= yield
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index e9687ed..95af10c 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,15 +1,18 @@
!!!
-%html
+%html{ lang: 'en' }
= render 'layouts/header'
- %body.signin
- .form-signin
- %p.text-center
- %a{ href: root_path }
- = image_tag 'logo_plain.png', alt: '', width: 72, class: 'mb-4'
+ %body
+ .container-narrow
+ %p.text-center.mb-6
+ = link_to root_path, class: 'brand justify-center text-[1.3rem]' do
+ %span.brand-dot
+ Castle
+ %span.text-muted{ style: 'font-weight:400' } demo
= render 'layouts/messages'
- = yield
+ .card
+ = yield
= render 'layouts/castle_js'
diff --git a/app/views/main/index.html.haml b/app/views/main/index.html.haml
index 2834074..ab995d4 100644
--- a/app/views/main/index.html.haml
+++ b/app/views/main/index.html.haml
@@ -1,16 +1,16 @@
-.p-5.mb-4.bg-body-tertiary.rounded-3
- .container-fluid.py-3
- %h1.display-5.fw-bold Castle Rails example app
- %p.col-md-10.fs-5
- A minimal Ruby on Rails + Devise application showing how to wire the
- = succeed '' do
- %a{ href: 'https://github.com/castle/castle-ruby' } castle-rb
- SDK into a real authentication flow.
+.hero
+ %span.tag castle-rb 8.1 · rails
+ %h1{ class: 'text-[2rem] mt-3' } Castle Rails example app
+ %p.lead.mt-2
+ A minimal Ruby on Rails + Devise application showing how to wire the
+ = succeed '' do
+ %a{ href: 'https://github.com/castle/castle-ruby' } castle-rb
+ SDK into a real authentication flow.
-.d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom
- %h2.h3 What's demonstrated
+.section-head
+ %h2{ class: 'text-[1.2rem]' } What's demonstrated
-%ul
+%ul.prose-list.list-disc.pl-5
%li
%strong Login
— successful logins are scored with the
@@ -31,14 +31,14 @@
— incoming Castle webhooks are signature-verified and listed under
= link_to 'Webhooks', integrations_castle_webhooks_path
-.d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom
- %h2.h3 Configuration
+.section-head
+ %h2{ class: 'text-[1.2rem]' } Configuration
%p
Copy
- %code.env.example
+ %code .env.example
to
- %code.env
+ %code .env
and fill in your Castle credentials
(CASTLE_API_SECRET and CASTLE_PK) from the
= succeed '.' do
diff --git a/app/views/users/profiles/edit.html.haml b/app/views/users/profiles/edit.html.haml
index f5454f2..6e96bd3 100644
--- a/app/views/users/profiles/edit.html.haml
+++ b/app/views/users/profiles/edit.html.haml
@@ -5,7 +5,7 @@
.form-inputs
= f.input :email, required: true, autofocus: true
.form-actions
- = f.button :submit, t('.button'), class: 'btn btn-lg btn-primary btn-block'
+ = f.button :submit, t('.button'), class: 'btn btn-primary w-full'
-%p.mb-4.mt-4
- = link_to t('.back'), root_path, class: 'btn btn-secondary'
+.btn-row
+ = link_to t('.back'), root_path, class: 'btn'
diff --git a/app/views/users/registrations/edit.html.haml b/app/views/users/registrations/edit.html.haml
index 75a9105..ca9c655 100644
--- a/app/views/users/registrations/edit.html.haml
+++ b/app/views/users/registrations/edit.html.haml
@@ -11,12 +11,12 @@
= f.input :password_confirmation, required: false
= f.input :current_password, hint: t('.current_password_hint'), required: true
.form-actions
- = f.button :submit, t('.button'), class: 'btn btn-lg btn-primary btn-block'
+ = f.button :submit, t('.button'), class: 'btn btn-primary w-full'
-%p.mb-4.mt-4
- = link_to t('.back'), root_path, class: 'btn btn-secondary'
+.btn-row.justify-between
+ = link_to t('.back'), root_path, class: 'btn'
= link_to t('.cancel'),
registration_path(resource_name),
data: { confirm: t('shared.confirm') },
method: :delete,
- class: 'btn btn-danger pull-right'
+ class: 'btn btn-danger'
diff --git a/app/views/users/registrations/new.html.haml b/app/views/users/registrations/new.html.haml
index 1fb89e4..4fc0f5e 100644
--- a/app/views/users/registrations/new.html.haml
+++ b/app/views/users/registrations/new.html.haml
@@ -8,6 +8,6 @@
= f.input :password, required: true, hint: hint
= f.input :password_confirmation, required: true
.form-actions
- = f.button :submit, t('.button'), class: 'btn btn-lg btn-primary btn-block'
+ = f.button :submit, t('.button'), class: 'btn btn-primary w-full'
= render "users/shared/links"
diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml
index 1eb0a0a..418ac5c 100644
--- a/app/views/users/sessions/new.html.haml
+++ b/app/views/users/sessions/new.html.haml
@@ -5,6 +5,6 @@
= f.input :email, required: false, autofocus: true
= f.input :password, required: false
.form-actions
- = f.button :submit, t('.button'), class: 'btn btn-lg btn-primary btn-block'
+ = f.button :submit, t('.button'), class: 'btn btn-primary w-full'
= render 'users/shared/links'
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index d268784..9a8de87 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -13,7 +13,7 @@
# wrapper, change the order or even add your own to the
# stack. The options given below are used to wrap the
# whole input.
- config.wrappers :default, class: :input,
+ config.wrappers :default, class: 'field',
hint_class: :field_with_hint, error_class: :field_with_errors, valid_class: :field_without_errors do |b|
## Extensions enabled by default
# Any of these extensions can be disabled for a
@@ -74,7 +74,7 @@
config.boolean_style = :nested
# Default class for buttons
- config.button_class = 'btn'
+ config.button_class = 'btn btn-primary'
# Method used to tidy up errors. Specify any Rails Array method.
# :first lists the first message for each field.
@@ -85,7 +85,7 @@
config.error_notification_tag = :div
# CSS class to add for error notification helper.
- config.error_notification_class = 'error_notification'
+ config.error_notification_class = 'alert alert-danger'
# Series of attempts to detect a default label method for collection.
# config.collection_label_methods = [ :to_label, :name, :title, :to_s ]
@@ -158,7 +158,7 @@
# config.cache_discovery = !Rails.env.development?
# Default class for inputs
- # config.input_class = nil
+ config.input_class = 'input'
# Define the default class of the input wrapper of the boolean input.
config.boolean_label_class = 'checkbox'
diff --git a/config/initializers/simple_form_bootstrap.rb b/config/initializers/simple_form_bootstrap.rb
deleted file mode 100644
index 7ec2ec6..0000000
--- a/config/initializers/simple_form_bootstrap.rb
+++ /dev/null
@@ -1,372 +0,0 @@
-# frozen_string_literal: true
-
-# These defaults are defined and maintained by the community at
-# https://github.com/heartcombo/simple_form-bootstrap
-# Please submit feedback, changes and tests only there.
-
-# Uncomment this and change the path if necessary to include your own
-# components.
-# See https://github.com/heartcombo/simple_form#custom-components
-# to know more about custom components.
-# Dir[Rails.root.join('lib/components/**/*.rb')].each { |f| require f }
-
-# Use this setup block to configure all options available in SimpleForm.
-SimpleForm.setup do |config|
- # Default class for buttons
- config.button_class = 'btn'
-
- # Define the default class of the input wrapper of the boolean input.
- config.boolean_label_class = 'form-check-label'
-
- # How the label text should be generated altogether with the required text.
- config.label_text = lambda { |label, required, explicit_label| "#{label} #{required}" }
-
- # Define the way to render check boxes / radio buttons with labels.
- config.boolean_style = :inline
-
- # You can wrap each item in a collection of radio/check boxes with a tag
- config.item_wrapper_tag = :div
-
- # Defines if the default input wrapper class should be included in radio
- # collection wrappers.
- config.include_default_input_wrapper_class = false
-
- # CSS class to add for error notification helper.
- config.error_notification_class = 'alert alert-danger'
-
- # Method used to tidy up errors. Specify any Rails Array method.
- # :first lists the first message for each field.
- # :to_sentence to list all errors for each field.
- config.error_method = :to_sentence
-
- # add validation classes to `input_field`
- config.input_field_error_class = 'is-invalid'
- config.input_field_valid_class = 'is-valid'
-
-
- # vertical forms
- #
- # vertical default_wrapper
- config.wrappers :vertical_form, class: 'mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :pattern
- b.optional :min_max
- b.optional :readonly
- b.use :label, class: 'form-label'
- b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical input for boolean
- config.wrappers :vertical_boolean, tag: 'fieldset', class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :form_check_wrapper, class: 'form-check' do |bb|
- bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'form-check-label'
- bb.use :full_error, wrap_with: { class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # vertical input for radio buttons and check boxes
- config.wrappers :vertical_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
- ba.use :label_text
- end
- b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical input for inline radio buttons and check boxes
- config.wrappers :vertical_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
- ba.use :label_text
- end
- b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical file input
- config.wrappers :vertical_file, class: 'mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :readonly
- b.use :label, class: 'form-label'
- b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical select input
- config.wrappers :vertical_select, class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'form-label'
- b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical multi select
- config.wrappers :vertical_multi_select, class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'form-label'
- b.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |ba|
- ba.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
- end
- b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # vertical range input
- config.wrappers :vertical_range, class: 'mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :readonly
- b.optional :step
- b.use :label, class: 'form-label'
- b.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
-
- # horizontal forms
- #
- # horizontal default_wrapper
- config.wrappers :horizontal_form, class: 'row mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :pattern
- b.optional :min_max
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal input for boolean
- config.wrappers :horizontal_boolean, class: 'row mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :grid_wrapper, class: 'col-sm-9 offset-sm-3' do |wr|
- wr.wrapper :form_check_wrapper, class: 'form-check' do |bb|
- bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'form-check-label'
- bb.use :full_error, wrap_with: { class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { class: 'form-text' }
- end
- end
- end
-
- # horizontal input for radio buttons and check boxes
- config.wrappers :horizontal_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', class: 'row mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label pt-0'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal input for inline radio buttons and check boxes
- config.wrappers :horizontal_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', class: 'row mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label pt-0'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal file input
- config.wrappers :horizontal_file, class: 'row mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal select input
- config.wrappers :horizontal_select, class: 'row mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal multi select
- config.wrappers :horizontal_multi_select, class: 'row mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'col-sm-3 col-form-label'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |bb|
- bb.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
- end
- ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
- # horizontal range input
- config.wrappers :horizontal_range, class: 'row mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :readonly
- b.optional :step
- b.use :label, class: 'col-sm-3 col-form-label pt-0'
- b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
- ba.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
-
- # inline forms
- #
- # inline default_wrapper
- config.wrappers :inline_form, class: 'col-12' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :pattern
- b.optional :min_max
- b.optional :readonly
- b.use :label, class: 'visually-hidden'
-
- b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :error, wrap_with: { class: 'invalid-feedback' }
- b.optional :hint, wrap_with: { class: 'form-text' }
- end
-
- # inline input for boolean
- config.wrappers :inline_boolean, class: 'col-12' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :form_check_wrapper, class: 'form-check' do |bb|
- bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'form-check-label'
- bb.use :error, wrap_with: { class: 'invalid-feedback' }
- bb.optional :hint, wrap_with: { class: 'form-text' }
- end
- end
-
-
- # bootstrap custom forms
- #
- # custom input switch for boolean
- config.wrappers :custom_boolean_switch, class: 'mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check form-switch' do |bb|
- bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'form-check-label'
- bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { class: 'form-text' }
- end
- end
-
-
- # Input Group - custom component
- # see example app and config at https://github.com/heartcombo/simple_form-bootstrap
- config.wrappers :input_group, class: 'mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :pattern
- b.optional :min_max
- b.optional :readonly
- b.use :label, class: 'form-label'
- b.wrapper :input_group_tag, class: 'input-group' do |ba|
- ba.optional :prepend
- ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.optional :append
- ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
- end
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
-
- # Floating Labels form
- #
- # floating labels default_wrapper
- config.wrappers :floating_labels_form, class: 'form-floating mb-3' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :maxlength
- b.optional :minlength
- b.optional :pattern
- b.optional :min_max
- b.optional :readonly
- b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :label
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
- # custom multi select
- config.wrappers :floating_labels_select, class: 'form-floating mb-3' do |b|
- b.use :html5
- b.optional :readonly
- b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :label
- b.use :full_error, wrap_with: { class: 'invalid-feedback' }
- b.use :hint, wrap_with: { class: 'form-text' }
- end
-
-
- # The default wrapper to be used by the FormBuilder.
- config.default_wrapper = :vertical_form
-
- # Custom wrappers for input types. This should be a hash containing an input
- # type as key and the wrapper that will be used for all inputs with specified type.
- config.wrapper_mappings = {
- boolean: :vertical_boolean,
- check_boxes: :vertical_collection,
- date: :vertical_multi_select,
- datetime: :vertical_multi_select,
- file: :vertical_file,
- radio_buttons: :vertical_collection,
- range: :vertical_range,
- time: :vertical_multi_select,
- select: :vertical_select
- }
-end
diff --git a/config/tailwind.config.js b/config/tailwind.config.js
new file mode 100644
index 0000000..e259ff5
--- /dev/null
+++ b/config/tailwind.config.js
@@ -0,0 +1,41 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ './public/*.html',
+ './app/helpers/**/*.rb',
+ './app/views/**/*.{erb,haml,html,slim}',
+ './app/assets/javascripts/**/*.js',
+ './config/initializers/simple_form*.rb',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ bg: '#0b0e14',
+ 'bg-soft': '#11151f',
+ surface: '#151a23',
+ 'surface-2': '#1b2230',
+ border: '#232b39',
+ 'border-soft': '#1c2330',
+ ink: '#e6e9ef',
+ muted: '#9aa4b2',
+ accent: '#7c5cff',
+ 'accent-hover': '#6b4cf0',
+ success: '#2ecc71',
+ challenge: '#ffbf47',
+ danger: '#ff5c7c',
+ },
+ fontFamily: {
+ sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
+ mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
+ },
+ borderRadius: {
+ xl: '14px',
+ lg: '9px',
+ },
+ boxShadow: {
+ card: '0 10px 30px rgba(0, 0, 0, 0.35)',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png
new file mode 100644
index 0000000..33126a7
Binary files /dev/null and b/docs/screenshots/home.png differ
diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png
new file mode 100644
index 0000000..a2498c5
Binary files /dev/null and b/docs/screenshots/login.png differ