diff --git a/.gitattributes b/.gitattributes
index 8dc432343..31eeee0b6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,5 +5,3 @@ db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
-config/credentials/*.yml.enc diff=rails_credentials
-config/credentials.yml.enc diff=rails_credentials
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index dbb544dc1..1efb99ec1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,7 +5,7 @@ registries:
type: git
url: https://github.com
username: x-access-token
- password: ${{secrets.GH_TOKEN}}
+ password: ${{secrets.FIZZY_GH_TOKEN}}
updates:
- package-ecosystem: bundler
diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml
index d097b9590..773674233 100644
--- a/.github/workflows/ci-checks.yml
+++ b/.github/workflows/ci-checks.yml
@@ -7,6 +7,20 @@ permissions:
contents: read
jobs:
+ gemfile-drift:
+ name: Gemfile drift
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+ bundler-cache: true
+
+ - name: Check for lockfile drift
+ run: bin/bundle-drift check
+
security:
name: Security
runs-on: ubuntu-latest
diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml
index aa98a8e93..afb91c04d 100644
--- a/.github/workflows/ci-saas.yml
+++ b/.github/workflows/ci-saas.yml
@@ -18,4 +18,4 @@ jobs:
with:
saas: true
secrets:
- GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index beee16f15..777471774 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,7 +7,7 @@ on:
type: boolean
required: true
secrets:
- GH_TOKEN:
+ FIZZY_GH_TOKEN:
required: false
permissions:
@@ -50,7 +50,7 @@ jobs:
MYSQL_USER: root
FIZZY_DB_HOST: 127.0.0.1
FIZZY_DB_PORT: 3306
- BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }}
+ BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }}
steps:
- name: Install system packages
diff --git a/.gitignore b/.gitignore
index 8ef4e9012..38bd80d0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,5 +41,3 @@
/config/credentials/*.key
.DS_Store
-
-.kamal/*
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index e8a5f8514..8c6d39d0f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,7 +2,7 @@ source "https://rubygems.org"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
-gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally"
+gem "rails", github: "rails/rails", branch: "main"
# Assets & front end
gem "importmap-rails"
diff --git a/Gemfile.lock b/Gemfile.lock
index d99375463..3a9430a61 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,8 +13,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
- branch: ast-immediate-variants-process-locally
+ revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901
+ branch: main
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -117,8 +117,8 @@ GEM
specs:
action_text-trix (2.1.15)
railties
- addressable (2.8.8)
- public_suffix (>= 2.0.2, < 8.0)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
@@ -151,8 +151,8 @@ GEM
brakeman (7.1.1)
racc
builder (3.3.0)
- bundler-audit (0.9.3)
- bundler (>= 1.2.0)
+ bundler-audit (0.9.2)
+ bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -185,15 +185,11 @@ GEM
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
- ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
- ffi (1.17.2-x86-linux-gnu)
- ffi (1.17.2-x86-linux-musl)
- ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
@@ -260,7 +256,6 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
- mini_portile2 (2.8.9)
minitest (5.26.2)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
@@ -272,7 +267,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
- mittens (0.3.1)
+ mittens (0.3.0)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -293,9 +288,6 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
- nokogiri (1.18.10)
- mini_portile2 (~> 2.8.2)
- racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
@@ -306,8 +298,6 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.18.10-x86_64-darwin)
- racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
@@ -332,7 +322,7 @@ GEM
psych (5.2.6)
date
stringio
- public_suffix (7.0.0)
+ public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -366,10 +356,10 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.6.1)
- rqrcode (3.1.1)
+ rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
- rqrcode_core (2.0.1)
+ rqrcode_core (2.0.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -388,7 +378,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
- rubocop-rails (2.34.1)
+ rubocop-rails (2.34.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -427,18 +417,13 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
- sqlite3 (2.8.1)
- mini_portile2 (~> 2.8.0)
- sqlite3 (2.8.1-aarch64-linux-gnu)
- sqlite3 (2.8.1-aarch64-linux-musl)
- sqlite3 (2.8.1-arm-linux-gnu)
- sqlite3 (2.8.1-arm-linux-musl)
- sqlite3 (2.8.1-arm64-darwin)
- sqlite3 (2.8.1-x86-linux-gnu)
- sqlite3 (2.8.1-x86-linux-musl)
- sqlite3 (2.8.1-x86_64-darwin)
- sqlite3 (2.8.1-x86_64-linux-gnu)
- sqlite3 (2.8.1-x86_64-linux-musl)
+ sqlite3 (2.8.0-aarch64-linux-gnu)
+ sqlite3 (2.8.0-aarch64-linux-musl)
+ sqlite3 (2.8.0-arm-linux-gnu)
+ sqlite3 (2.8.0-arm-linux-musl)
+ sqlite3 (2.8.0-arm64-darwin)
+ sqlite3 (2.8.0-x86_64-linux-gnu)
+ sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -453,7 +438,6 @@ GEM
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
- thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
@@ -497,11 +481,6 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
- arm64-linux
- ruby
- x86-linux-gnu
- x86-linux-musl
- x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 91c11943f..3d12fc6c7 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,7 +21,7 @@ GIT
GIT
remote: https://github.com/basecamp/fizzy-saas
- revision: a14df11b57818697df4b2cc7b6a43e762ebaa196
+ revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143
specs:
fizzy-saas (0.1.0)
audits1984
@@ -50,7 +50,7 @@ GIT
GIT
remote: https://github.com/basecamp/queenbee-plugin
- revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2
+ revision: 14312a940471e20617b38cdec7c092a01567d18b
specs:
queenbee (3.2.0)
activeresource
@@ -82,8 +82,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
- revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
- branch: ast-immediate-variants-process-locally
+ revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901
+ branch: main
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
diff --git a/README.md b/README.md
index 100d46b0b..41b78dcd1 100644
--- a/README.md
+++ b/README.md
@@ -2,201 +2,27 @@
This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com).
-## Deploying Fizzy
-If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/).
-Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses.
+## Running your own Fizzy instance
-(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)
+If you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image.
+You'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation.
-This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.
+You can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md).
-The steps to configure your very own Fizzy are:
-
-1. Fork the repo
-2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`.
-3. Edit few things in `config/deploy.yml` and `.kamal/secrets`
-4. Run `kamal setup` to do your first deploy.
-
-We'll go through each of these in turn.
-
-### Fork the repo
-
-To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.
-That allows you to commit your changes, and track them over time.
-You can always re-sync your fork to pick up new changes from the main repo over time.
-
-Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.
-
-### Editing the configuration
-
-The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.
-To get started, the parts you need to change are all in the "About your deployment" section.
-We've added comments to that file to highlight what each setting needs to be, but the main ones are:
-
-- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.
-- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
-- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
-- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
-- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
-
-Fizzy also requires a few environment variables to be set up, some of which contain secrets.
-The simplest way to do this is to put them in a file called `.kamal/secrets`.
-Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally.
-
-If you use a password manager like 1Password, you can also opt to keep your secrets there instead.
-Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that.
-
-To store your secrets, create the file `.kamal/secrets` and enter something like the following:
-
-```ini
-SECRET_KEY_BASE=12345
-VAPID_PUBLIC_KEY=something
-VAPID_PRIVATE_KEY=somethingelse
-SMTP_USERNAME=email-provider-username
-SMTP_PASSWORD=email-provider-password
-```
-
-The values you enter here will be specific to you, and you can get or create them as follows:
-
-- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
-- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.
-- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
-
- ```sh
- bin/rails c
- ```
-
- And then run the following to create a new pair of keys:
-
- ```ruby
- vapid_key = WebPush.generate_key
-
- puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
- puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
- ```
-
-Once you've made all those changes, commit them to your fork so they're saved.
-
-### Deploy Fizzy!
-
-You can now do your first deploy by running:
-
-```sh
-bin/kamal setup
-```
-
-This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running.
-
-After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run:
-
-```sh
-bin/kamal deploy
-```
-
-## File storage (Active Storage)
-
-Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.
-
-To use the included `s3` service, set:
-
-- `ACTIVE_STORAGE_SERVICE=s3`
-- `S3_ACCESS_KEY_ID`
-- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)
-- `S3_REGION` (defaults to `us-east-1`)
-- `S3_SECRET_ACCESS_KEY`
-
-Optional for S3-compatible endpoints:
-
-- `S3_ENDPOINT`
-- `S3_FORCE_PATH_STYLE=true`
-- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)
-- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)
+If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md).
## Development
-### Setting up
+You are welcome -- and encouraged -- to modify Fizzy to your liking.
+Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development.
-First, get everything installed and configured with:
-
-```sh
-bin/setup
-bin/setup --reset # Reset the database and seed it
-```
-
-And then run the development server:
-
-```sh
-bin/dev
-```
-
-You'll be able to access the app in development at http://fizzy.localhost:3006.
-
-To login, enter `david@example.com` and grab the verification code from the browser console to sign in.
-
-### Web Push Notifications
-
-Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
-
-- `VAPID_PRIVATE_KEY`
-- `VAPID_PUBLIC_KEY`
-
-Generate them with the `web-push` gem:
-
-```ruby
-vapid_key = WebPush.generate_key
-
-puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
-puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
-```
-
-### Running tests
-
-For fast feedback loops, unit tests can be run with:
-
-```sh
-bin/rails test
-```
-
-The full continuous integration tests can be run with:
-
-```sh
-bin/ci
-```
-
-### Database configuration
-
-Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL:
-
-```sh
-DATABASE_ADAPTER=mysql bin/setup --reset
-DATABASE_ADAPTER=mysql bin/ci
-```
-
-The remote CI pipeline will run tests against both SQLite and MySQL.
-
-### Outbound Emails
-
-You can view email previews at http://fizzy.localhost:3006/rails/mailers.
-
-You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with:
-
-```sh
-bin/rails dev:email
-```
-
-Under the hood, this will create or remove `tmp/email-dev.txt`.
-
-## SaaS gem
-
-37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup.
-
-This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure.
## Contributing
We welcome contributions! Please read our [style guide](STYLE.md) before submitting code.
+
## License
Fizzy is released under the [O'Saasy License](LICENSE.md).
diff --git a/app/assets/images/monitor.svg b/app/assets/images/monitor.svg
new file mode 100644
index 000000000..a1140a2a8
--- /dev/null
+++ b/app/assets/images/monitor.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/moon.svg b/app/assets/images/moon.svg
new file mode 100644
index 000000000..8db6c1b09
--- /dev/null
+++ b/app/assets/images/moon.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/sun.svg b/app/assets/images/sun.svg
new file mode 100644
index 000000000..6dd6e54c8
--- /dev/null
+++ b/app/assets/images/sun.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css
index 52aa53805..dc928b40d 100644
--- a/app/assets/stylesheets/_global.css
+++ b/app/assets/stylesheets/_global.css
@@ -10,7 +10,7 @@
--block-space-double: calc(var(--block-space) * 2);
/* Text */
- --font-sans: -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
@@ -255,8 +255,117 @@
--color-gradient-2: oklch(var(--lch-pink-lighter));
--color-gradient-3: oklch(var(--lch-purple-lighter));
--color-gradient-4: var(--color-canvas);
+}
- @media (prefers-color-scheme: dark) {
+/* Dark mode - explicit theme choice overrides system preference */
+html[data-theme="dark"] {
+ --lch-canvas: 20% 0.0195 232.58;
+ --lch-ink-inverted: var(--lch-black);
+
+ --lch-ink-darkest: 96.02% 0.0034 260;
+ --lch-ink-darker: 86% 0.0061 260;
+ --lch-ink-dark: 73.97% 0.009 260;
+ --lch-ink-medium: 62% 0.0122 260;
+ --lch-ink-light: 40% 0.0148 260;
+ --lch-ink-lighter: 30% 0.0178 260;
+ --lch-ink-lightest: 25% 0.0204 260;
+
+ --lch-uncolor-darkest: 96.09% 0.0076 100;
+ --lch-uncolor-darker: 86% 0.021 90;
+ --lch-uncolor-dark: 73.93% 0.041 80;
+ --lch-uncolor-medium: 62% 0.0552 70;
+ --lch-uncolor-light: 40% 0.0387 60;
+ --lch-uncolor-lighter: 30% 0.012 50;
+ --lch-uncolor-lightest: 25% 0.0017 40;
+
+ --lch-red-darkest: 95.85% 0.0218 46;
+ --lch-red-darker: 86% 0.086 44;
+ --lch-red-dark: 73.95% 0.139 42;
+ --lch-red-medium: 62% 0.154 40;
+ --lch-red-light: 40% 0.088 38;
+ --lch-red-lighter: 30% 0.032 36;
+ --lch-red-lightest: 25% 0.011 34;
+
+ --lch-yellow-darkest: 96% 0.056 100;
+ --lch-yellow-darker: 86% 0.103 90;
+ --lch-yellow-dark: 74.06% 0.136 80;
+ --lch-yellow-medium: 62.1% 0.146 70;
+ --lch-yellow-light: 40% 0.0736 60;
+ --lch-yellow-lighter: 30% 0.026 50;
+ --lch-yellow-lightest: 25% 0.01 40;
+
+ --lch-lime-darkest: 96.04% 0.066 115;
+ --lch-lime-darker: 86% 0.098 114;
+ --lch-lime-dark: 73.97% 0.121 113;
+ --lch-lime-medium: 62% 0.128 112;
+ --lch-lime-light: 40% 0.0637 111;
+ --lch-lime-lighter: 30% 0.024 110;
+ --lch-lime-lightest: 25% 0.012 109;
+
+ --lch-green-darkest: 96.12% 0.035 143;
+ --lch-green-darker: 86% 0.082 144;
+ --lch-green-dark: 73.99% 0.117 145;
+ --lch-green-medium: 62% 0.1261 146;
+ --lch-green-light: 40% 0.065 147;
+ --lch-green-lighter: 30% 0.03 148;
+ --lch-green-lightest: 25% 0.018 149;
+
+ --lch-aqua-darkest: 96.15% 0.0244 202;
+ --lch-aqua-darker: 86% 0.06 204;
+ --lch-aqua-dark: 73.92% 0.095 206;
+ --lch-aqua-medium: 62% 0.106 208;
+ --lch-aqua-light: 40% 0.0594 210;
+ --lch-aqua-lighter: 30% 0.028 212;
+ --lch-aqua-lightest: 25% 0.017 214;
+
+ --lch-blue-darkest: 95.93% 0.0217 252;
+ --lch-blue-darker: 86% 0.068 254;
+ --lch-blue-dark: 74% 0.1293 256;
+ --lch-blue-medium: 62% 0.159 258;
+ --lch-blue-light: 40% 0.094 260;
+ --lch-blue-lighter: 30% 0.0452 262;
+ --lch-blue-lightest: 25% 0.0318 264;
+
+ --lch-violet-darkest: 95.97% 0.019 280;
+ --lch-violet-darker: 86% 0.068 282;
+ --lch-violet-dark: 74.08% 0.142 284;
+ --lch-violet-medium: 62% 0.184 286;
+ --lch-violet-light: 40% 0.108 288;
+ --lch-violet-lighter: 30% 0.048 290;
+ --lch-violet-lightest: 25% 0.025 292;
+
+ --lch-purple-darkest: 95.99% 0.0217 302;
+ --lch-purple-darker: 86% 0.068 304;
+ --lch-purple-dark: 73.98% 0.141 306;
+ --lch-purple-medium: 62% 0.177 308;
+ --lch-purple-light: 40% 0.099 310;
+ --lch-purple-lighter: 30% 0.04 312;
+ --lch-purple-lightest: 25% 0.017 314;
+
+ --lch-pink-darkest: 95.84% 0.0308 336;
+ --lch-pink-darker: 86% 0.074 338;
+ --lch-pink-dark: 74.04% 0.1294 340;
+ --lch-pink-medium: 62% 0.166 342;
+ --lch-pink-light: 40% 0.085 344;
+ --lch-pink-lighter: 30% 0.03 346;
+ --lch-pink-lightest: 25% 0.011 348;
+
+ --color-terminal-bg: var(--color-canvas);
+ --color-terminal-text-light: oklch(var(--lch-green-dark));
+ --color-golden: oklch(var(--lch-blue-medium));
+ --color-highlight: oklch(var(--lch-blue-lighter));
+
+ --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),
+ 0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),
+ 0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7),
+ 0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8),
+ 0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9),
+ 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1);
+}
+
+/* Fallback to system preference when no explicit theme is set */
+@media (prefers-color-scheme: dark) {
+ html:not([data-theme]) {
--lch-canvas: 20% 0.0195 232.58;
--lch-ink-inverted: var(--lch-black);
diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css
index 86183ccb0..e13badb7b 100644
--- a/app/assets/stylesheets/bar.css
+++ b/app/assets/stylesheets/bar.css
@@ -19,10 +19,16 @@
view-transition-name: bar;
z-index: var(--z-bar);
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
border-block: 1px solid var(--color-ink-lighter);
}
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ border-block: 1px solid var(--color-ink-lighter);
+ }
+ }
+
&:has(.bar__placeholder[hidden]) {
padding-inline: 1ch;
}
diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css
index 31c4180e1..05d2b016e 100644
--- a/app/assets/stylesheets/base.css
+++ b/app/assets/stylesheets/base.css
@@ -55,9 +55,15 @@
::selection {
background: var(--color-selected);
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
background-color: var(--color-selected-dark);
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ background-color: var(--color-selected-dark);
+ }
+ }
}
:where(ul, ol):where([role="list"]) {
diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css
index 274d80ceb..4ee5f199e 100644
--- a/app/assets/stylesheets/buttons.css
+++ b/app/assets/stylesheets/buttons.css
@@ -27,10 +27,16 @@
}
}
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
--btn-hover-brightness: 1.25;
}
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ --btn-hover-brightness: 1.25;
+ }
+ }
+
&[disabled],
&:has([disabled]),
[disabled] &[type=submit],
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index ac690ac30..c239e1b9d 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -179,9 +179,15 @@
outline: var(--focus-ring-size) solid var(--color-selected-dark);
outline-offset: var(--focus-ring-offset);
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
outline-color: oklch(var(--lch-blue-medium));
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ outline-color: oklch(var(--lch-blue-medium));
+ }
+ }
}
}
@@ -658,38 +664,39 @@
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
- .cards--considering:has(.bubble:not([hidden])),
- .cards--doing.is-collapsed:has(.bubble:not([hidden])) {
- .cards__transition-container {
- --bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
- --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
+ .cards--considering:has(.bubble:not([hidden])) .cards__expander-title,
+ .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
+ --bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
+ --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
+
+ &:before {
+ background: radial-gradient(
+ color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
+ color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
+ );
+ block-size: 1em;
+ border-radius: var(--bubble-shape);
+ content: "";
+ inline-size: 1em;
+ inset: 0 0 auto auto;
+ position: absolute;
+ translate: 20% -20%;
+ z-index: 1;
+ }
+
+ /* Maybe column: position bubble relative to the title, not the container */
+ .cards--considering & {
+ overflow: visible;
+ position: relative;
&:before {
- background: radial-gradient(
- color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
- color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
- );
- block-size: 1em;
- border-radius: var(--bubble-shape);
- content: "";
- inline-size: 1em;
- inset: 0 0 auto auto;
- position: absolute;
- translate: 20% -20%;
- z-index: 1;
- }
-
- .cards--considering &:before {
- inset: 0 auto auto 0;
- translate: 100% 75%;
+ inset-block-start: 50%;
+ translate: 125% -75%;
+ z-index: -1;
}
}
}
- .cards--considering:has(.bubble:not([hidden])) .cards__expander-title:before {
- translate: 120% 50%;
- }
-
/* Card column indicators
/* -------------------------------------------------------------------------- */
@@ -758,7 +765,7 @@
block-size: 1px;
inline-size: 100%;
inset: 50% 0 auto;
- translaate: 0 -50%;
+ translate: 0 -50%;
}
&:after {
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index 9fe9c6601..3fb0a7f4d 100644
--- a/app/assets/stylesheets/cards.css
+++ b/app/assets/stylesheets/cards.css
@@ -27,10 +27,16 @@
text-align: start;
z-index: 1;
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ box-shadow: 0 0 0 1px var(--color-ink-lighter);
+ }
+ }
+
.popup {
inline-size: 260px;
}
diff --git a/app/assets/stylesheets/circled-text.css b/app/assets/stylesheets/circled-text.css
index 9c3e87f48..dcb971d51 100644
--- a/app/assets/stylesheets/circled-text.css
+++ b/app/assets/stylesheets/circled-text.css
@@ -12,9 +12,15 @@
opacity: 0.5;
mix-blend-mode: multiply;
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
mix-blend-mode: screen;
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ mix-blend-mode: screen;
+ }
+ }
}
span::before,
diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css
index 8e9f19e89..2e269b9d0 100644
--- a/app/assets/stylesheets/icons.css
+++ b/app/assets/stylesheets/icons.css
@@ -72,6 +72,8 @@
.icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); }
.icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); }
.icon--minus { --svg: url("minus.svg "); }
+ .icon--monitor { --svg: url("monitor.svg "); }
+ .icon--moon { --svg: url("moon.svg "); }
.icon--move { --svg: url("move.svg "); }
.icon--notification-bell-access-only { --svg: url("bell.svg "); }
.icon--notification-bell-watching { --svg: url("bell-off.svg "); }
@@ -96,6 +98,7 @@
.icon--settings { --svg: url("settings.svg "); }
.icon--share { --svg: url("share.svg "); }
.icon--sliders { --svg: url("sliders.svg "); }
+ .icon--sun { --svg: url("sun.svg "); }
.icon--switch { --svg: url("switch.svg "); }
.icon--tag { --svg: url("tag.svg "); }
.icon--tag-outline { --svg: url("tag-outline.svg "); }
diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css
index 109bfbb26..252ef2835 100644
--- a/app/assets/stylesheets/inputs.css
+++ b/app/assets/stylesheets/inputs.css
@@ -104,17 +104,25 @@
.input--select {
--input-border-radius: 2em;
--input-padding: 0.5em 1.8em 0.5em 1.2em;
+ --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
+ --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
-webkit-appearance: none;
appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
+ background-image: var(--caret-icon);
background-size: 0.5em;
background-position: center right 0.9em;
background-repeat: no-repeat;
text-align: start;
+ html[data-theme="dark"] & {
+ --caret-icon: var(--caret-icon-dark);
+ }
+
@media (prefers-color-scheme: dark) {
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
+ html:not([data-theme]) & {
+ --caret-icon: var(--caret-icon-dark);
+ }
}
option {
diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css
index cf73d217b..0605b12cb 100644
--- a/app/assets/stylesheets/knobs.css
+++ b/app/assets/stylesheets/knobs.css
@@ -24,6 +24,7 @@
block-size: var(--knob-size);
inline-size: var(--knob-size);
inset: 50% auto auto 50%;
+ opacity: 0;
position: absolute;
translate: -50% -50%;
z-index: 1;
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index f89028f02..c90d5f6b9 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -55,10 +55,13 @@
}
.lexxy-code-language-picker {
+ --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
+ --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
+
-webkit-appearance: none;
appearance: none;
background-color: var(--color-canvas);
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
+ background-image: var(--caret-icon);
background-position: center right 0.9em;
background-repeat: no-repeat;
background-size: 0.5em;
@@ -75,8 +78,14 @@
padding-inline: 1.5ch 1.8em;
text-align: start;
+ html[data-theme="dark"] & {
+ --caret-icon: var(--caret-icon-dark);
+ }
+
@media (prefers-color-scheme: dark) {
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
+ html:not([data-theme]) & {
+ --caret-icon: var(--caret-icon-dark);
+ }
}
option {
diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/markdown.css
index 5dfac832c..f6aef0e39 100644
--- a/app/assets/stylesheets/markdown.css
+++ b/app/assets/stylesheets/markdown.css
@@ -24,8 +24,15 @@
}
}
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
filter: invert(1);
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ filter: invert(1);
+ }
+ }
+
}
}
diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css
index b50189366..57d1803bd 100644
--- a/app/assets/stylesheets/nav.css
+++ b/app/assets/stylesheets/nav.css
@@ -23,9 +23,15 @@
background: var(--color-ink-lighter);
}
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
--input-background: var(--color-ink-lighter);
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ --input-background: var(--color-ink-lighter);
+ }
+ }
}
}
@@ -221,7 +227,7 @@
.nav__blank-slate {
font-size: var(--text-small);
- margin: 1rem auto;
+ margin: 2rem auto;
.nav:has(.popup__item:not([hidden])) & {
display: none;
diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css
index ed6187bae..797e9a7cb 100644
--- a/app/assets/stylesheets/reactions.css
+++ b/app/assets/stylesheets/reactions.css
@@ -95,9 +95,16 @@
&:not(.expanded):hover {
filter: brightness(var(--reaction-hover-brightness));
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
--reaction-hover-brightness: 1.25;
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ --reaction-hover-brightness: 1.25;
+ }
+ }
+
}
}
}
diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css
index c9877f4bf..70f095bca 100644
--- a/app/assets/stylesheets/rich-text-content.css
+++ b/app/assets/stylesheets/rich-text-content.css
@@ -29,6 +29,7 @@
}
:where(ol, ul) {
+ list-style: disc;
padding-inline-start: 3ch;
}
diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css
index c5390e793..96368841e 100644
--- a/app/assets/stylesheets/search.css
+++ b/app/assets/stylesheets/search.css
@@ -69,8 +69,8 @@ summary {
.search__empty {
margin-block: 3em;
- opacity: 0.66;
- text-align: center;
+ margin-inline: auto;
+ inline-size: fit-content;
}
.search__excerpt {
diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css
index 7ddc2cf42..7da03d67b 100644
--- a/app/assets/stylesheets/syntax.css
+++ b/app/assets/stylesheets/syntax.css
@@ -14,7 +14,7 @@
--markup-deleted: lch(39.64 68.17 31.45);
/* Redefine named color values for dark mode */
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] {
--keyword: lch(67.63 58.99 30.64);
--entity: lch(75.13 46.73 306.74);
--constant: lch(74.9 39.71 255.53);
@@ -28,6 +28,22 @@
--markup-deleted: lch(73.8% 65 29.18);
}
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) {
+ --keyword: lch(67.63 58.99 30.64);
+ --entity: lch(75.13 46.73 306.74);
+ --constant: lch(74.9 39.71 255.53);
+ --string: lch(74.9 39.71 255.53);
+ --variable: lch(76.17 61.1 61.97);
+ --comment: lch(60.83 6.66 254.46);
+ --entity-tag: lch(83.65 59.31 141.61);
+ --markup-heading: lch(47.93 71.67 280.72);
+ --markup-list: lch(83.84 57.9 85.03);
+ --markup-inserted: lch(83.65 59.31 141.61);
+ --markup-deleted: lch(73.8% 65 29.18);
+ }
+ }
+
color: var(--color-ink);
.w {
diff --git a/app/assets/stylesheets/theme-switcher.css b/app/assets/stylesheets/theme-switcher.css
new file mode 100644
index 000000000..4b3b1bae2
--- /dev/null
+++ b/app/assets/stylesheets/theme-switcher.css
@@ -0,0 +1,33 @@
+@layer components {
+ .theme-switcher {
+ @media (max-width: 479px) {
+ --row-gap: 1ch;
+
+ flex-direction: column;
+ }
+ }
+
+ .theme-switcher__btn {
+ --btn-background: var(--color-ink-lightest);
+ --btn-border-radius: 0.4em;
+ --btn-border-size: 0;
+ --btn-gap: 0.1lh;
+ --btn-padding: 1em;
+ --icon-size: 2em;
+
+ column-gap: var(--inline-space);
+ flex: 1;
+ flex-direction: column;
+ position: relative;
+ white-space: nowrap;
+
+ &:has(input:checked) {
+ --btn-background: var(--color-selected);
+ --btn-color: var(--color-ink);
+ }
+
+ @media (max-width: 479px) {
+ flex-direction: row;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css
index 46c4b74b7..3cefc1ecc 100644
--- a/app/assets/stylesheets/trays.css
+++ b/app/assets/stylesheets/trays.css
@@ -300,9 +300,16 @@
border-radius: 0;
}
- @media (prefers-color-scheme: dark) {
+ html[data-theme="dark"] & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ box-shadow: 0 0 0 1px var(--color-ink-lighter);
+ }
+ }
+
}
.card__background {
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index 8223d3801..83706ddf4 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
- params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ])
+ params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
end
end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 30f54820b..deb8b3fa3 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -6,6 +6,7 @@ module Authentication
before_action :require_authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
+ helper_method :email_address_pending_authentication
etag { Current.identity.id if authenticated? }
@@ -37,7 +38,7 @@ module Authentication
def require_account
unless Current.account.present?
- redirect_to session_menu_url(script_name: nil)
+ redirect_to main_app.session_menu_path(script_name: nil)
end
end
@@ -78,11 +79,11 @@ module Authentication
end
def redirect_authenticated_user
- redirect_to root_url if authenticated?
+ redirect_to main_app.root_url if authenticated?
end
def redirect_tenanted_request
- redirect_to root_url if Current.account.present?
+ redirect_to main_app.root_url if Current.account.present?
end
def start_new_session_for(identity)
@@ -107,10 +108,24 @@ module Authentication
end
end
+ def email_address_pending_authentication_matches?(email_address)
+ if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "")
+ session.delete(:email_address_pending_authentication)
+ true
+ else
+ false
+ end
+ end
+
+ def email_address_pending_authentication
+ session[:email_address_pending_authentication]
+ end
+
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
+ session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link
session[:return_to_after_authenticating] = return_to if return_to
- redirect_to session_magic_link_url(script_name: nil)
+ redirect_to main_app.session_magic_link_path(script_name: nil)
end
def serve_development_magic_link(magic_link)
diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb
index 81bd0c781..1afb878ef 100644
--- a/app/controllers/concerns/authorization.rb
+++ b/app/controllers/concerns/authorization.rb
@@ -3,7 +3,6 @@ module Authorization
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
- before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated?
end
class_methods do
@@ -27,11 +26,7 @@ module Authorization
end
def ensure_can_access_account
- redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active?
- end
-
- def ensure_only_staff_can_access_non_production_remote_environments
- head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff?
+ redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def redirect_existing_user
diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb
index 95fa12892..c8481beb3 100644
--- a/app/controllers/concerns/request_forgery_protection.rb
+++ b/app/controllers/concerns/request_forgery_protection.rb
@@ -12,16 +12,21 @@ module RequestForgeryProtection
end
def verified_request?
- super || safe_fetch_site? || request.format.json?
+ request.get? || request.head? || !protect_against_forgery? ||
+ (valid_request_origin? && safe_fetch_site?)
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
def safe_fetch_site?
- SAFE_FETCH_SITES.include?(sec_fetch_site_value)
+ SAFE_FETCH_SITES.include?(sec_fetch_site_value) || (sec_fetch_site_value.nil? && api_request?)
+ end
+
+ def api_request?
+ request.format.json?
end
def sec_fetch_site_value
- request.headers["Sec-Fetch-Site"].to_s.downcase
+ request.headers["Sec-Fetch-Site"].to_s.downcase.presence
end
end
diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb
index 71fa1b9ac..c0632407a 100644
--- a/app/controllers/sessions/magic_links_controller.rb
+++ b/app/controllers/sessions/magic_links_controller.rb
@@ -2,6 +2,7 @@ class Sessions::MagicLinksController < ApplicationController
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
+ before_action :ensure_that_email_address_pending_authentication_exists
layout "public"
@@ -10,14 +11,28 @@ class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
- start_new_session_for magic_link.identity
- redirect_to after_sign_in_url(magic_link)
+ authenticate_with magic_link
else
redirect_to session_magic_link_path, flash: { shake: true }
end
end
private
+ def ensure_that_email_address_pending_authentication_exists
+ unless email_address_pending_authentication.present?
+ redirect_to new_session_path, alert: "Enter your email address to sign in."
+ end
+ end
+
+ def authenticate_with(magic_link)
+ if email_address_pending_authentication_matches?(magic_link.identity.email_address)
+ start_new_session_for magic_link.identity
+ redirect_to after_sign_in_url(magic_link)
+ else
+ redirect_to new_session_path, alert: "Authentication failed. Please try again."
+ end
+ end
+
def code
params.expect(:code)
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index fb9fe0617..f5044bd2a 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -14,7 +14,8 @@ class SessionsController < ApplicationController
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
- redirect_to_session_magic_link signup.create_identity
+ magic_link = signup.create_identity if Account.accepting_signups?
+ redirect_to_session_magic_link magic_link
else
head :unprocessable_entity
end
diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb
index 776421201..56614158f 100644
--- a/app/controllers/signups_controller.rb
+++ b/app/controllers/signups_controller.rb
@@ -3,6 +3,7 @@ class SignupsController < ApplicationController
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
before_action :redirect_authenticated_user
+ before_action :enforce_tenant_limit
layout "public"
@@ -24,6 +25,10 @@ class SignupsController < ApplicationController
redirect_to new_signup_completion_path if authenticated?
end
+ def enforce_tenant_limit
+ redirect_to new_session_url unless Account.accepting_signups?
+ end
+
def signup_params
params.expect signup: :email_address
end
diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb
index d85e5f869..420dbe5b6 100644
--- a/app/controllers/users/avatars_controller.rb
+++ b/app/controllers/users/avatars_controller.rb
@@ -1,6 +1,4 @@
class Users::AvatarsController < ApplicationController
- include ActiveStorage::Streaming
-
allow_unauthenticated_access only: :show
before_action :set_user
diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb
index f5e89c8d1..6004282a6 100644
--- a/app/helpers/login_helper.rb
+++ b/app/helpers/login_helper.rb
@@ -1,7 +1,5 @@
module LoginHelper
def login_url
- # Use main_app because this helper may be invoked from an engine controller
- # that inherits from AdminController.
main_app.new_session_path(script_name: nil)
end
diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js
new file mode 100644
index 000000000..154bf4fc0
--- /dev/null
+++ b/app/javascript/controllers/theme_controller.js
@@ -0,0 +1,49 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["lightButton", "darkButton", "autoButton"]
+
+ connect() {
+ this.#applyStoredTheme()
+ }
+
+ setLight() {
+ this.#theme = "light"
+ }
+
+ setDark() {
+ this.#theme = "dark"
+ }
+
+ setAuto() {
+ this.#theme = "auto"
+ }
+
+ get #storedTheme() {
+ return localStorage.getItem("theme") || "auto"
+ }
+
+ set #theme(theme) {
+ localStorage.setItem("theme", theme)
+
+ if (theme === "auto") {
+ document.documentElement.removeAttribute("data-theme")
+ } else {
+ document.documentElement.setAttribute("data-theme", theme)
+ }
+
+ this.#updateButtons()
+ }
+
+ #applyStoredTheme() {
+ this.#theme = this.#storedTheme
+ }
+
+ #updateButtons() {
+ const storedTheme = this.#storedTheme
+
+ if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") }
+ if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") }
+ if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") }
+ }
+}
diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js
deleted file mode 100644
index 1fb986f70..000000000
--- a/app/javascript/helpers/http_helpers.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const HttpStatus = {
- CONFLICT: 409,
- UNPROCESSABLE: 422
-}
diff --git a/app/models/account.rb b/app/models/account.rb
index 34aaf64ae..df0b845b0 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
- include Account::Storage, Entropic, Seedeable
+ include Account::Storage, Entropic, MultiTenantable, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb
index 79160cbaa..6438d3ab8 100644
--- a/app/models/account/join_code.rb
+++ b/app/models/account/join_code.rb
@@ -11,8 +11,8 @@ class Account::JoinCode < ApplicationRecord
before_create :generate_code, if: -> { code.blank? }
def redeem_if(&block)
- transaction do
- increment!(:usage_count) if block.call(account)
+ with_lock do
+ increment!(:usage_count) if active? && block.call(account)
end
end
diff --git a/app/models/account/multi_tenantable.rb b/app/models/account/multi_tenantable.rb
new file mode 100644
index 000000000..7d1ef5d39
--- /dev/null
+++ b/app/models/account/multi_tenantable.rb
@@ -0,0 +1,13 @@
+module Account::MultiTenantable
+ extend ActiveSupport::Concern
+
+ included do
+ cattr_accessor :multi_tenant, default: false
+ end
+
+ class_methods do
+ def accepting_signups?
+ multi_tenant || Account.none?
+ end
+ end
+end
diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb
index b533318e3..143561c39 100644
--- a/app/models/card/activity_spike/detector.rb
+++ b/app/models/card/activity_spike/detector.rb
@@ -27,7 +27,7 @@ class Card::ActivitySpike::Detector
def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2)
card.comments
- .where("created_at >= ?", recent_period.seconds.ago)
+ .where(created_at: recent_period.seconds.ago..)
.group(:card_id)
.having("COUNT(*) >= ?", minimum_comments)
.having("COUNT(DISTINCT creator_id) >= ?", minimum_participants)
@@ -51,6 +51,6 @@ class Card::ActivitySpike::Detector
end
def last_event
- card.events.order(created_at: :desc).first
+ card.events.order(:created_at).last
end
end
diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb
index 51852c9a5..e4cea509f 100644
--- a/app/models/card/closeable.rb
+++ b/app/models/card/closeable.rb
@@ -7,9 +7,9 @@ module Card::Closeable
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
- scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
- scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) }
- scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) }
+ scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
+ scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
+ scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
end
def closed?
diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb
index 520fd95d4..94544656d 100644
--- a/app/models/card/eventable.rb
+++ b/app/models/card/eventable.rb
@@ -4,7 +4,7 @@ module Card::Eventable
include ::Eventable
included do
- before_create { self.last_active_at = Time.current }
+ before_create { self.last_active_at ||= created_at || Time.current }
after_save :track_title_change, if: :saved_change_to_title?
end
@@ -12,7 +12,7 @@ module Card::Eventable
def event_was_created(event)
transaction do
create_system_comment_for(event)
- touch_last_active_at
+ touch_last_active_at unless was_just_published?
end
end
diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb
index 545cc87ee..03348dfde 100644
--- a/app/models/card/stallable.rb
+++ b/app/models/card/stallable.rb
@@ -7,7 +7,7 @@ module Card::Stallable
has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy
scope :with_activity_spikes, -> { joins(:activity_spike) }
- scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
+ scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
before_update :remember_to_detect_activity_spikes
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
diff --git a/app/models/comment.rb b/app/models/comment.rb
index a396a904f..d0315eea8 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -10,8 +10,8 @@ class Comment < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
- scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) }
- scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) }
+ scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
+ scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) }
after_create_commit :watch_card_by_creator
diff --git a/app/models/filter.rb b/app/models/filter.rb
index 9dac977bd..9bfefe176 100644
--- a/app/models/filter.rb
+++ b/app/models/filter.rb
@@ -29,7 +29,7 @@ class Filter < ApplicationRecord
result = result.where(creator_id: creators.ids) if creators.present?
result = result.where(board: boards.ids) if boards.present?
result = result.tagged_with(tags.ids) if tags.present?
- result = result.where("cards.created_at": creation_window) if creation_window
+ result = result.where(cards: { created_at: creation_window }) if creation_window
result = result.closed_at_window(closure_window) if closure_window
result = result.closed_by(closers) if closers.present?
result = terms.reduce(result) do |result, term|
diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb
index 2bcc0c4e6..0a7d97e59 100644
--- a/app/models/magic_link/code.rb
+++ b/app/models/magic_link/code.rb
@@ -1,18 +1,17 @@
module MagicLink::Code
- CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze
CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
- Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
+ SecureRandom.base32(length)
end
def sanitize(code)
- return nil if code.blank?
-
- normalize_code(code)
- .then { apply_substitutions(_1) }
- .then { remove_invalid_characters(_1) }
+ if code.present?
+ normalize_code(code)
+ .then { apply_substitutions(it) }
+ .then { remove_invalid_characters(it) }
+ end
end
private
@@ -25,7 +24,7 @@ module MagicLink::Code
end
def remove_invalid_characters(code)
- code.gsub(/[^#{CODE_ALPHABET.join}]/, "")
+ code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "")
end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 20ee1c40c..6503881a3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,8 +14,6 @@ class User < ApplicationRecord
has_many :pinned_cards, through: :pins, source: :card
has_many :exports, class_name: "Account::Export", dependent: :destroy
- scope :with_avatars, -> { preload(:account, :avatar_attachment) }
-
def deactivate
transaction do
accesses.destroy_all
diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb
index c635edddb..400738cb1 100644
--- a/app/models/user/avatar.rb
+++ b/app/models/user/avatar.rb
@@ -2,13 +2,20 @@ module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
+ MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
included do
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
end
- validate :avatar_content_type_allowed
+ scope :with_avatars, -> { preload(:account, :avatar_attachment) }
+
+ validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached?
+ end
+
+ def avatar_attached?
+ avatar.attached?
end
def avatar_thumbnail
@@ -17,8 +24,23 @@ module User::Avatar
private
def avatar_content_type_allowed
- if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
+ if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
end
end
+
+ def avatar_dimensions_allowed
+ return unless avatar.blob.analyzed? || avatar.blob.analyze
+
+ width = avatar.blob.metadata[:width]
+ height = avatar.blob.metadata[:height]
+
+ if width && width > MAX_AVATAR_DIMENSIONS[:width]
+ errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px")
+ end
+
+ if height && height > MAX_AVATAR_DIMENSIONS[:height]
+ errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px")
+ end
+ end
end
diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb
index 53f5064ce..daac70a4a 100644
--- a/app/views/account/settings/_entropy.html.erb
+++ b/app/views/account/settings/_entropy.html.erb
@@ -1,6 +1,6 @@
Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board. Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.Auto close
-