diff --git a/.github/workflows/dependabot-sync-saas-lockfile.yml b/.github/workflows/dependabot-sync-saas-lockfile.yml
new file mode 100644
index 000000000..5afe97244
--- /dev/null
+++ b/.github/workflows/dependabot-sync-saas-lockfile.yml
@@ -0,0 +1,35 @@
+name: Sync Gemfile.saas.lock
+
+on:
+ push:
+ branches:
+ - "dependabot/bundler/**"
+ paths:
+ - Gemfile.lock
+
+permissions:
+ contents: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+
+ - name: Forward Gemfile.lock changes to Gemfile.saas.lock
+ run: bin/bundle-drift forward
+
+ - name: Commit updated lockfile
+ run: |
+ git add Gemfile.saas.lock
+ if ! git diff --cached --quiet; then
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git commit -m "Sync Gemfile.saas.lock"
+ git push
+ fi
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index d89381607..676e6b7a3 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -48,7 +48,7 @@ jobs:
uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
- uses: docker/login-action@v3.6.0
+ uses: docker/login-action@v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -101,7 +101,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
- uses: actions/attest-build-provenance@v3.1.0
+ uses: actions/attest-build-provenance@v3.2.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -121,7 +121,7 @@ jobs:
uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
- uses: docker/login-action@v3.6.0
+ uses: docker/login-action@v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
diff --git a/Gemfile b/Gemfile
index 23b1223ce..c524ee3c1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9"
gem "bcrypt", "~> 3.1.7"
gem "geared_pagination", "~> 1.2"
gem "rqrcode"
-gem "redcarpet"
gem "rouge"
gem "jbuilder"
gem "lexxy", bc: "lexxy"
@@ -41,6 +40,7 @@ gem "useragent", bc: "useragent"
# Operations
gem "autotuner"
gem "mission_control-jobs"
+gem "stackprof"
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
group :development, :test do
diff --git a/Gemfile.lock b/Gemfile.lock
index 86a856e37..559b2990b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,8 +1,8 @@
GIT
remote: https://github.com/basecamp/lexxy
- revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
+ revision: 4bcf8d0213971560e90734493f8bd835addc3642
specs:
- lexxy (0.7.0.beta)
+ lexxy (0.7.4.beta)
rails (>= 8.0.2)
GIT
@@ -147,9 +147,9 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.21.1)
+ bootsnap (1.22.0)
msgpack (~> 1.2)
- brakeman (7.1.2)
+ brakeman (8.0.1)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -184,7 +184,7 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- faker (3.5.3)
+ faker (3.6.0)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -355,7 +355,6 @@ GEM
erb
psych (>= 4.0.0)
tsort
- redcarpet (3.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -400,7 +399,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
- selenium-webdriver (4.39.0)
+ selenium-webdriver (4.40.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -437,6 +436,7 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
+ stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -520,7 +520,6 @@ DEPENDENCIES
puma (>= 5.0)
rack-mini-profiler
rails!
- redcarpet
rouge
rqrcode
rubocop-rails-omakase
@@ -529,6 +528,7 @@ DEPENDENCIES
solid_cache (~> 1.0)
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
+ stackprof
stimulus-rails
thruster
trilogy (~> 2.9)
diff --git a/Gemfile.saas b/Gemfile.saas
index e022ab6b2..b93efb1ee 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -24,3 +24,4 @@ gem "yabeda-puma-plugin"
gem "yabeda-rails"
gem "webrick" # required for yabeda-prometheus metrics server
gem "prometheus-client-mmap", "~> 1.3"
+gem "gvltools"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 8de89e1fb..b09872738 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/basecamp/console1984
- revision: 02b1b9ee7fd7050174b6a98c2b43057553621dc4
+ revision: 23419ec8861e73f12ced75a3fc559d03f7be5854
specs:
console1984 (0.2.2)
irb (~> 1.13)
@@ -23,9 +23,9 @@ GIT
GIT
remote: https://github.com/basecamp/lexxy
- revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
+ revision: 4bcf8d0213971560e90734493f8bd835addc3642
specs:
- lexxy (0.7.0.beta)
+ lexxy (0.7.4.beta)
rails (>= 8.0.2)
GIT
@@ -227,9 +227,9 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.21.1)
+ bootsnap (1.22.0)
msgpack (~> 1.2)
- brakeman (7.1.2)
+ brakeman (8.0.1)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -265,7 +265,7 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- faker (3.5.3)
+ faker (3.6.0)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -282,6 +282,7 @@ GEM
addressable (>= 2.5.0)
globalid (1.3.0)
activesupport (>= 6.1)
+ gvltools (0.4.0)
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
@@ -466,7 +467,6 @@ GEM
erb
psych (>= 4.0.0)
tsort
- redcarpet (3.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -513,7 +513,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
- selenium-webdriver (4.39.0)
+ selenium-webdriver (4.40.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -558,6 +558,7 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
+ stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -657,6 +658,7 @@ DEPENDENCIES
faker
fizzy-saas!
geared_pagination (~> 1.2)
+ gvltools
image_processing (~> 1.14)
importmap-rails
jbuilder
@@ -675,7 +677,6 @@ DEPENDENCIES
rack-mini-profiler
rails!
rails_structured_logging!
- redcarpet
rouge
rqrcode
rubocop-rails-omakase
@@ -686,6 +687,7 @@ DEPENDENCIES
solid_cache (~> 1.0)
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
+ stackprof
stimulus-rails
stripe (~> 18.0)
thruster
diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css
new file mode 100644
index 000000000..85dc7652d
--- /dev/null
+++ b/app/assets/stylesheets/attachments.css
@@ -0,0 +1,207 @@
+@layer components {
+ .attachment {
+ block-size: auto;
+ display: block;
+ inline-size: fit-content;
+ position: relative;
+ max-inline-size: 100%;
+
+ progress {
+ inline-size: 100%;
+ margin: auto;
+ }
+ }
+
+ .attachment__caption {
+ color: color-mix(in oklch, var(--color-ink) 66%, transparent);
+ font-size: var(--text-small);
+
+ textarea {
+ --input-border-radius: 0.3em;
+ --input-border-size: 0;
+ --input-padding: 0;
+
+ background-color: var(--input-background, transparent);
+ border: none;
+ color: inherit;
+ inline-size: 100%;
+ max-inline-size: 100%;
+ resize: none;
+ text-align: center;
+
+ &:focus {
+ --focus-ring-size: 0;
+ }
+
+ @supports (field-sizing: content) {
+ field-sizing: content;
+ inline-size: auto;
+ min-inline-size: 20ch;
+ }
+ }
+ }
+
+ .attachment__icon {
+ aspect-ratio: 4/5;
+ background-color: color-mix(var(--attachment-icon-color), transparent 90%);
+ block-size: 2.5lh;
+ border: 2px solid var(--attachment-icon-color);
+ border-block-start-width: 1ch;
+ border-radius: 0.5ch;
+ box-sizing: border-box;
+ color: var(--attachment-icon-color);
+ display: grid;
+ font-size: var(--text-small);
+ font-weight: bold;
+ inline-size: auto;
+ padding-inline: 0.5ch;
+ place-content: center;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+
+ .attachment--preview {
+ margin-inline: auto;
+ text-align: center;
+
+ img, video {
+ block-size: auto;
+ display: block;
+ margin-inline: auto;
+ max-inline-size: 100%;
+ user-select: none;
+ }
+
+ > a {
+ display: block;
+ }
+
+ .attachment__caption {
+ column-gap: 0.5ch;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ margin-block-start: 0.5ch;
+ }
+ }
+
+ .attachment--file {
+ --attachment-icon-color: var(--color-ink-medium);
+
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1ch;
+ inline-size: 100%;
+ margin-inline: 0;
+
+ .attachment__caption {
+ display: grid;
+ flex: 1;
+ text-align: start;
+ }
+
+ .attachment__name {
+ color: var(--color-ink);
+ font-weight: bold;
+ }
+
+ /* Video attachments don't have an identifiable class, but we need to
+ * make sure the caption is always below the video */
+ &:has(video) {
+ .attachment__caption {
+ flex: none;
+ inline-size: 100%;
+ }
+ }
+ }
+
+ .attachment--psd,
+ .attachment--key,
+ .attachment--sketch,
+ .attachment--ai,
+ .attachment--eps,
+ .attachment--indd,
+ .attachment--svg,
+ .attachment--ppt,
+ .attachment--pptx {
+ --attachment-icon-color: oklch(var(--lch-red-medium));
+ }
+
+ .attachment--css,
+ .attachment--crash,
+ .attachment--php,
+ .attachment--json,
+ .attachment--htm,
+ .attachment--html,
+ .attachment--rb,
+ .attachment--erb,
+ .attachment--ts,
+ .attachment--js {
+ --attachment-icon-color: oklch(var(--lch-purple-medium));
+ }
+
+ .attachment--txt,
+ .attachment--pages,
+ .attachment--rtf,
+ .attachment--md,
+ .attachment--doc,
+ .attachment--docx {
+ --attachment-icon-color: oklch(var(--lch-blue-medium));
+ }
+
+ .attachment--csv &,
+ .attachment--numbers &,
+ .attachment--xls &,
+ .attachment--xlsx & {
+ --attachment-icon-color: oklch(var(--lch-green-medium));
+ }
+
+ .attachment__link {
+ color: var(--color-link);
+ text-decoration: underline;
+ }
+
+ /* Custom attachments such as mentions, etc. */
+ action-text-attachment[content-type^='application/vnd.actiontext'] {
+ --attachment-bg-color: transparent;
+ --attachment-image-size: 1em;
+ --attachment-text-color: currentColor;
+
+ align-items: center;
+ background: var(--attachment-bg-color);
+ border-radius: 99rem;
+ box-shadow:
+ -0.25ch 0 0 var(--attachment-bg-color),
+ 0.5ch 0 0 var(--attachment-bg-color);
+ color: var(--attachment-text-color);
+ display: inline-flex;
+ gap: 0.25ch;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ vertical-align: bottom;
+ white-space: normal;
+
+ lexxy-editor & {
+ cursor: pointer;
+ }
+
+ img {
+ block-size: var(--attachment-image-size);
+ border-radius: 50%;
+ inline-size: var(--attachment-image-size);
+ }
+
+ &.node--selected {
+ --attachment-bg-color: oklch(var(--lch-blue-dark));
+ --attachment-text-color: var(--color-ink-inverted);
+ }
+ }
+
+ action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
+ img {
+ object-fit: cover;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css
index 689b80215..0c76ee544 100644
--- a/app/assets/stylesheets/base.css
+++ b/app/assets/stylesheets/base.css
@@ -34,7 +34,7 @@
:is(a, button, input, textarea, .switch, .btn) {
transition: 100ms ease-out;
- transition-property: background-color, border-color, box-shadow, filter, outline;
+ transition-property: background-color, border-color, box-shadow, outline;
touch-action: manipulation;
/* Keyboard navigation */
diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css
index 89638fc12..5f15dc278 100644
--- a/app/assets/stylesheets/buttons.css
+++ b/app/assets/stylesheets/buttons.css
@@ -19,7 +19,7 @@
pointer-events: auto;
position: relative;
transition: 100ms ease-out;
- transition-property: background-color, border, box-shadow, color, filter, opacity, scale;
+ transition-property: background-color, border, box-shadow, color, opacity, scale;
@media (any-hover: hover) {
&:hover {
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 7cb14e15b..e2b576058 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -185,15 +185,16 @@
.cards__transition-container {
block-size: 100%;
border-radius: calc(var(--column-width-collapsed) / 2);
- translate: 0 0.5ch; /* Allow a little room for the mini bubble */
+ margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */
transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle);
@media (min-width: 640px) {
.is-expanded & {
- translate: 0 0.5ch; /* Allow a little room for the mini bubble */
+ translate: 0; /* Animate back from collapsed state */
}
.is-collapsed & {
+ margin-block-start: 0;
translate: 0 var(--column-width-collapsed);
}
}
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index b3d37d68a..68d0cb2db 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -34,6 +34,26 @@
}
}
+ @media (max-width: 639px) {
+ &:has(.card__closed) {
+ padding-block-start: 2rem;
+ }
+
+ &:has(.reactions .reaction) {
+ .card__meta-avatars--assignees {
+ margin-inline-end: unset;
+ }
+ }
+
+ &:not(:has(.reactions .reaction)) {
+ &:has(.card__closed) {
+ .card__meta-text--assignees {
+ margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block));
+ }
+ }
+ }
+ }
+
@media (max-width: 799px) {
--half-btn-height: 1.25rem;
--padding-inline: 1.5ch;
@@ -63,12 +83,33 @@
opacity: 0.2;
}
+ .card__header {
+ @media (max-width: 639px) {
+ flex-wrap: wrap;
+ gap: var(--card-header-space) unset;
+ }
+ }
+
+ .card__tags {
+ @media (max-width: 639px) {
+ padding: 0.25lh;
+ }
+ }
+
+ .card__tags-list {
+ @media (min-width: 640px) {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+
.card__body {
position: relative;
@media (max-width: 639px) {
flex-direction: column;
- padding-block-end: calc(var(--card-padding-block) * 1.5);
+ padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
}
}
@@ -90,12 +131,24 @@
}
&:has(textarea) {
- margin-block-end: 0;
+ @media (min-width: 640px) {
+ margin-block-end: 0;
+ }
@supports not (field-sizing: content) {
text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */
}
}
+
+ @media (max-width: 639px) {
+ margin-block-end: 0.75ch;
+ }
+ }
+
+ .card__description {
+ @media (max-width: 639px) {
+ margin-block-end: 1ch;
+ }
}
.card__meta,
@@ -108,6 +161,43 @@
.card__meta {
grid-area: meta;
margin-inline-end: auto;
+
+ @media (max-width: 639px) {
+ --meta-spacer-block: 0.75ch;
+
+ inline-size: 100%;
+ grid-template-areas:
+ "avatars-author text-added"
+ "avatars-author text-author"
+ "text-updated text-updated"
+ "text-assignees text-assignees"
+ "avatars-assignees avatars-assignees";
+ grid-template-columns: auto 1fr;
+
+ .card__meta-text {
+ border: 0;
+ padding: 0;
+ }
+
+ .card__meta-text + .card__meta-text {
+ border-block-start: var(--card-border);
+ margin-block-start: var(--meta-spacer-block);
+ padding-block-start: var(--meta-spacer-block);
+ }
+
+ .card__meta-avatars--assignees {
+ margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block));
+ margin-block-start: var(--meta-spacer-block);
+ }
+
+ .card__meta-avatars--author {
+ display: initial;
+ }
+
+ .card__meta-avatars--assignees .avatar {
+ display: grid;
+ }
+ }
}
.card__stages {
@@ -115,12 +205,12 @@
@media (max-width: 639px) {
border: 1px solid var(--card-color);
- border-radius: 0.25em;
+ border-radius: calc(0.2em + 3px);
flex-direction: row;
gap: 0;
overflow: auto;
max-inline-size: 100%;
- padding: 0;
+ padding: 3px;
position: relative;
white-space: nowrap;
@@ -130,7 +220,7 @@
min-block-size: 2.5em;
}
- & > form + form:not(:has(.card__column-name--current)) {
+ & > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) {
box-shadow: -1px 0 0 0 var(--color-container);
}
}
@@ -138,7 +228,6 @@
.card__column-name {
@media (max-width: 639px) {
- --btn-border-radius: 0;
justify-content: center;
}
}
@@ -163,6 +252,7 @@
@media (max-width: 639px) {
font-size: var(--text-x-small);
+ position: relative;
}
}
@@ -189,14 +279,24 @@
inset: 0 calc(-1 * var(--card-padding-inline)) auto;
position: absolute;
}
+
+ @media (any-hover: none) {
+ --reaction-size: 2.25rem;
+ }
}
&:not(:has(.reaction)) {
- position: static;
-
.reactions__trigger {
--btn-border-color: var(--color-ink-light);
}
+
+ @media (max-width: 639px) {
+ inset-inline-end: 0;
+ }
+
+ @media (min-width: 640px) {
+ position: static;
+ }
}
}
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index cbaa66c91..27dbb2c46 100644
--- a/app/assets/stylesheets/cards.css
+++ b/app/assets/stylesheets/cards.css
@@ -220,6 +220,15 @@
.card__title-link {
color: inherit;
}
+
+ code {
+ background-color: var(--color-canvas);
+ border: 1px solid var(--color-ink-lighter);
+ border-radius: 0.25ch;
+ font-family: var(--font-mono);
+ font-size: smaller;
+ padding: 0.1ch 0.25ch;
+ }
}
.card__description {
@@ -229,7 +238,7 @@
}
lexxy-toolbar {
- border-block-start: 1px solid var(--color-ink-light);
+ border-block-start: 1px solid var(--lexxy-border-color);
}
& ~ .btn.btn--reversed {
@@ -372,6 +381,7 @@
--stamp-color: oklch(var(--lch-green-medium) / 0.65);
align-items: center;
+ backdrop-filter: blur(2px);
background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent);
border-radius: 0.2em;
border: 0.5ch solid var(--stamp-color);
@@ -399,6 +409,12 @@
}
}
}
+
+ @media (max-width: 639px) {
+ inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto;
+ translate: 0 40%;
+ scale: 75%;
+ }
}
.card:has(.card__closed),
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index a8605503b..01a9fbe91 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -91,6 +91,17 @@
.btn {
font-weight: inherit;
}
+
+ @media (max-width: 639px) {
+ margin-block-end: calc(var(--block-space-half) / 2);
+
+ h3 {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ column-gap: 0.4em;
+ }
+ }
}
.comment__avatar {
@@ -155,7 +166,7 @@
text-transform: capitalize;
@media (max-width: 639px) {
- line-height: 1.5lh;
+ font-size: var(--text-small);
}
}
diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css
index 1336cad9b..22574979d 100644
--- a/app/assets/stylesheets/dialog.css
+++ b/app/assets/stylesheets/dialog.css
@@ -7,32 +7,37 @@
:is(.dialog) {
border: 0;
opacity: 0;
- transform: scale(0.2);
- transform-origin: top center;
- transition: var(--dialog-duration) allow-discrete;
+ transform: scale(0.85);
+ transform-origin: center;
+ transition-behavior: allow-discrete;
+ transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */
transition-property: display, opacity, overlay, transform;
+ transition-timing-function: ease-out;
&::backdrop {
background-color: var(--color-black);
opacity: 0;
- transform: scale(1);
- transition: var(--dialog-duration) allow-discrete;
+ transition-behavior: allow-discrete;
+ transition-duration: calc(var(--dialog-duration) / 2);
transition-property: display, opacity, overlay;
+ transition-timing-function: ease-out;
}
&[open] {
opacity: 1;
transform: scale(1);
+ transition-duration: var(--dialog-duration); /* Normal opening speed */
&::backdrop {
opacity: 0.5;
+ transition-duration: var(--dialog-duration);
}
}
@starting-style {
&[open] {
opacity: 0;
- transform: scale(0.2);
+ transform: scale(0.85);
}
&[open]::backdrop {
diff --git a/app/assets/stylesheets/import.css b/app/assets/stylesheets/import.css
new file mode 100644
index 000000000..5ab6b2cc5
--- /dev/null
+++ b/app/assets/stylesheets/import.css
@@ -0,0 +1,33 @@
+@layer components {
+ .import-status {
+ --import-status-border-color: var(--color-ink-light);
+ --import-status-color: var(--color-ink);
+
+ border: 1px dashed var(--import-status-border-color);
+ border-radius: 1ch;
+ color: var(--import-status-color);
+ font-size: var(--text-medium);
+ padding: 1.5ch;
+
+ .btn {
+ font-size: var(--text-small);
+ margin-block-start: 1.5ch;
+ }
+ }
+
+ .import-status--success {
+ --import-status-border-color: var(--color-positive);
+ --import-status-color: var(--color-positive);
+ }
+
+ .import-status--error {
+ --import-status-border-color: var(--color-negative);
+ --import-status-color: var(--color-negative);
+ }
+
+ @keyframes dash {
+ to {
+ background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index c35f14fee..0b33162fb 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -1,515 +1,187 @@
+@import url("lexxy-variables.css") layer(base);
+@import url("lexxy-content.css") layer(base);
+@import url("lexxy-editor.css") layer(base);
+
+:root {
+ --lexxy-color-ink: var(--color-ink);
+ --lexxy-color-ink-medium: var(--color-ink-dark);
+ --lexxy-color-ink-light: var(--color-ink-medium);
+ --lexxy-color-ink-lighter: var(--color-ink-light);
+ --lexxy-color-ink-lightest: var(--color-ink-lighter);
+ --lexxy-color-ink-inverted: var(--color-ink-inverted);
+
+ --lexxy-color-canvas: var(--color-canvas);
+
+ --lexxy-color-accent-dark: var(--color-accent-dark);
+ --lexxy-color-accent-medium: var(--color-accent-medium);
+ --lexxy-color-accent-light: var(--color-accent-light);
+ --lexxy-color-accent-lightest: var(--color-accent-lightest);
+
+ --lexxy-color-red: oklch(var(--lch-red-medium));
+ --lexxy-color-green: oklch(var(--lch-green-medium));
+ --lexxy-color-blue: oklch(var(--lch-blue-medium));
+ --lexxy-color-purple: oklch(var(--lch-purple-medium));
+
+ --lexxy-color-code-token-att: var(--color-code-token__att);
+ --lexxy-color-code-token-comment: var(--color-code-token__comment);
+ --lexxy-color-code-token-function: var(--color-code-token__function);
+ --lexxy-color-code-token-operator: var(--color-code-token__operator);
+ --lexxy-color-code-token-property: var(--color-code-token__property);
+ --lexxy-color-code-token-punctuation: var(--color-code-token__punctuation);
+ --lexxy-color-code-token-selector: var(--color-code-token__selector);
+ --lexxy-color-code-token-variable: var(--color-code-token__variable);
+
+ --lexxy-color-selected: oklch(var(--lch-blue-light));
+ --lexxy-color-selected-dark: oklch(var(--lch-blue-medium));
+
+ --lexxy-color-table-cell-border: var(--color-ink-ligher);
+ --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
+ --lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
+ --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
+}
+
@layer components {
/* Editor
/* ------------------------------------------------------------------------ */
lexxy-editor {
- display: block;
- position: relative;
- overflow: visible;
+ --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%);
+ --lexxy-editor-padding: 0;
+ --lexxy-toolbar-button-size: 2rem;
- figure.node--selected,
- div.node--selected {
- &:has(img) {
- img {
- outline: var(--focus-ring-size) solid var(--focus-ring-color);
- outline-offset: var(--focus-ring-offset);
- }
- }
-
- &:not(:has(img)) {
- outline: var(--focus-ring-size) solid var(--focus-ring-color);
- outline-offset: var(--focus-ring-offset);
- }
- }
- }
-
- .lexxy-content__table-wrapper {
- margin: 0;
- margin-block: 1ch;
- overflow-x: auto;
- }
-
- table {
- th, td {
- &.table-cell--selected {
- background-color: var(--color-selected-light);
- }
-
- &.lexxy-content__table-cell--selected {
- background-color: var(--color-selected);
- border-color: var(--color-selected-dark);
- }
- }
-
- &.lexxy-content__table--selection {
- ::selection {
- background: transparent;
- }
- }
- }
-
- /* Lexical uses the `lexxy-editor--empty` class even if you have a list
- * started but haven't added other characters. Here, we hide the placeholder
- * when you click the List button in the toolbar. */
- .lexxy-editor--empty {
- .lexxy-editor__content:not(:has(ul, ol))::before {
- content: attr(placeholder);
- color: currentColor;
- cursor: text;
- opacity: 0.66;
- pointer-events: none;
- position: absolute;
- white-space: pre-line;
- }
- }
-
- .lexxy-editor__content {
- margin-block-start: var(--block-space-half);
- min-block-size: calc(7lh + var(--block-space));
- outline: 0;
-
- /* Allow color highlights to show through a bit */
- ::selection {
- background: oklch(var(--lch-blue-light) / 0.5);
- }
-
- > :last-child {
- margin-block-end: 0;
- }
- }
-
- .lexxy-editor--drag-over {
- background-color: var(--color-selected);
- border-radius: 4px;
- outline: 2px dashed var(--color-selected-dark);
- }
-
- .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: var(--caret-icon);
- background-position: center right 0.9em;
- background-repeat: no-repeat;
- background-size: 0.5em;
- border: 1px solid var(--color-ink-light);
- border-radius: 99rem;
- color: var(--color-ink);
- cursor: pointer;
- font-family: var(--font-base);
- font-size: var(--text-x-small);
- font-weight: 500;
- inset-inline-end: 0;
- line-height: 1.15lh;
- margin: 0.75ch 0.75ch 0 0;
- padding-inline: 1.5ch 1.8em;
- text-align: start;
-
- html[data-theme="dark"] & {
- --caret-icon: var(--caret-icon-dark);
- }
-
- @media (prefers-color-scheme: dark) {
- html:not([data-theme]) & {
- --caret-icon: var(--caret-icon-dark);
- }
- }
-
- option {
- background-color: var(--color-canvas);
- color: var(--color-ink);
- }
- }
-
- /* Toolbar
- /* ------------------------------------------------------------------------ */
-
- /* TODO: Temporary - hide table button while we work on the styles */
- button[name="table"] {
- display: none;
+ background-color: transparent;
+ border: none;
+ border-radius: 0;
}
lexxy-toolbar {
- --lexxy-toolbar-icon-size: 1em;
-
- background-color: var(--lexxy-bg-color, var(--color-canvas));
- border-block-end: 1px solid var(--color-ink-light);
- color: currentColor;
- display: flex;
- font-size: inherit;
- margin: 0;
- max-inline-size: 100%;
- padding: 0.2em 0;
- position: relative;
- position: sticky;
- inset-block-start: 0;
- z-index: 2;
+ border-color: var(--lexxy-border-color);
+ gap: 0;
}
.lexxy-editor__toolbar-button {
- --toolbar-button-size: 44px;
+ background: transparent;
- appearance: none;
- aspect-ratio: 1;
- background-color: transparent;
- block-size: var(--toolbar-button-size);
- border: none;
- border-radius: 0.2em;
- color: currentColor;
- cursor: pointer;
- display: grid;
- font-size: inherit;
- place-items: center;
-
- &:is(:focus, :hover) {
- background-color: var(--color-ink-lighter);
- box-shadow: none;
+ &[aria-pressed="true"],
+ [open] > & {
+ background-color: oklch(var(--lch-blue-medium) / 20%);
}
- &:is(:active),
- &[aria-pressed="true"] {
- background-color: var(--color-selected);
- }
-
- svg {
- -webkit-touch-callout: none;
- block-size: var(--lexxy-toolbar-icon-size);
- fill: currentColor;
- grid-area: 1/1;
- inline-size: var(--lexxy-toolbar-icon-size);
- user-select: none;
- }
-
- @media (min-width: 640px) {
- --toolbar-button-size: 2em;
- }
- }
-
- .lexxy-editor__toolbar-overflow-menu {
- background-color: var(--color-canvas);
- border-radius: 0.5ch;
- box-shadow: var(--shadow);
- display: flex;
- flex-wrap: wrap;
- inset-inline-end: 0;
- padding: 4px;
- position: absolute;
- z-index: 2;
- }
-
- .lexxy-editor__toolbar-spacer {
- flex: 1;
- }
-
- /* Dropdowns
- /* ------------------------------------------------------------------------ */
-
- .lexxy-editor__toolbar-dropdown-content {
- --lexxy-dropdown-padding: 0.75rem;
- --lexxy-dropdown-btn-size: 2rem;
-
- background-color: var(--color-canvas);
- border: 1px solid var(--color-ink-lighter);
- border-radius: 0.5em;
- box-shadow: var(--shadow);
- color: var(--color-ink);
- font-size: var(--text-small);
- padding: var(--lexxy-dropdown-padding);
- position: absolute;
- z-index: 2;
-
- button {
- block-size: var(--lexxy-dropdown-btn-size);
- }
- }
-
- .lexxy-editor__toolbar-dropdown-actions {
- display: flex;
- font-size: var(--text-x-small);
- flex: 1 1 0%;
- gap: var(--lexxy-dropdown-padding);
- margin-block-start: var(--lexxy-dropdown-padding);
-
- .btn {
- --radius: 99rem;
-
- inline-size: 100%;
- }
-
- .btn[type="submit"] {
- --btn-background: var(--card-color, var(--color-link));
- --btn-color: var(--color-ink-inverted);
- --focus-ring-color: var(--card-color, var(--color-link));
- }
-
- span {
- inline-size: 100%;
+ @media(any-hover: hover) {
+ &:hover:not([aria-pressed="true"]) {
+ background-color: oklch(var(--lch-ink-dark) / 20%);
+ }
}
}
lexxy-link-dropdown {
- .input {
- min-inline-size: 30ch;
- }
- }
+ --lexxy-toolbar-spacing: 1.5ch;
- lexxy-highlight-dropdown {
- --gap: 0.5ch;
+ .lexxy-editor__toolbar-button {
+ border-radius: 99rem;
- [data-button-group] {
- display: flex;
- flex-direction: row;
- gap: var(--gap);
-
- + & {
- margin-block-start: var(--gap);
- }
-
- @media (max-width: 639px) {
- flex-wrap: wrap;
- }
- }
- }
-
- .lexxy-highlight-button {
- --outline-color: oklch(var(--lch-ink-darkest) / 0.2);
-
- appearance: none;
- background: var(--color-canvas);
- border: none;
- border-radius: 0.5ch;
- color: inherit;
- display: grid;
- font-weight: 500;
- inline-size: var(--lexxy-dropdown-btn-size);
- place-content: center;
- position: relative;
- outline: none;
-
- &:after {
- content: "Aa";
- }
-
- &:hover,
- &[aria-pressed="true"] {
- box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
- }
-
- &[aria-pressed="true"] {
- --outline-color: var(--color-link);
-
- &:after {
- content: "✓";
- }
- }
- }
-
- .lexxy-editor__toolbar-dropdown-reset {
- background: var(--color-canvas);
- border: 1px solid var(--color-ink-light);
- border-radius: 99rem;
- inline-size: 100%;
- margin-block-start: var(--lexxy-dropdown-padding);
-
- &[disabled] {
- display: none;
- }
- }
-
- .lexxy-table-handle-buttons {
- --button-size: 2.5em;
-
- color: var(--color-ink-inverted);
- display: none;
- flex-direction: row;
- font-size: var(--text-x-small);
- gap: 0.25ch;
- line-height: 1;
- position: absolute;
- transform: translate(-50%, -120%);
- z-index: 1;
-
- &:has([open]) {
- z-index: 4;
- }
-
- .lexxy-table-control {
- align-items: center;
- background-color: var(--color-ink);
- border-radius: 0.75ch;
- display: flex;
- flex-direction: row;
- gap: 1ch;
- padding: 2px;
- white-space: nowrap;
-
- button,
- summary {
- aspect-ratio: 1 / 1;
- align-items: center;
- background-color: transparent;
- border-radius: 0.5ch;
- border: 0;
- color: var(--color-ink-inverted);
- cursor: pointer;
- display: flex;
- font-size: inherit;
- font-weight: 700;
- justify-content: center;
- line-height: 1;
- list-style: none;
- min-block-size: var(--button-size);
- min-inline-size: var(--button-size);
- padding: 0;
- user-select: none;
- -webkit-user-select: none;
-
+ @media(any-hover: hover) {
&:hover {
- background-color: var(--color-ink-darker);
+ filter: brightness(0.9);
+ opacity: 1;
}
+ }
- &:focus,
- &:focus-visible {
- background-color: var(--color-ink-darker);
- outline: var(--focus-ring-size) solid var(--focus-ring-color);
- outline-offset: var(--focus-ring-offset);
- }
-
- svg {
- block-size: 1em;
- inline-size: 1em;
- fill: currentColor;
- }
-
- span {
- display: none;
- }
+ &[type="submit"],
+ &[type="submit"]:hover {
+ background-color: var(--color-link);
}
- }
-
- .lexxy-table-control__more-menu {
- gap: 0;
- padding: 2px;
- position: relative;
-
- summary {
- &::-webkit-details-marker {
- display: none;
- }
- }
-
- .lexxy-table-control__more-menu-details {
- display: flex;
- flex-direction: column;
- gap: 0.25ch;
- inset-block-start: 105%;
- inset-inline-end: 0;
- padding: 0;
- position: absolute;
-
- .lexxy-table-control__more-menu-section {
- background: var(--color-ink);
- border-radius: 0.75ch;
- display: flex;
- flex-direction: column;
- padding: 2px;
- }
-
- button {
- aspect-ratio: unset;
- align-items: center;
- flex-direction: row;
- font-weight: normal;
- gap: 1ch;
- justify-content: flex-start;
- padding: 0.5ch 2ch;
- padding-inline-start: 1ch;
- white-space: nowrap;
-
- svg {
- block-size: 1.3em;
- inline-size: 1.3em;
- }
-
- span {
- display: inline-block;
- }
- }
+
+ &[type="button"] {
+ border: 1px solid var(--color-ink-light);
}
}
}
- /* Prompt menu (@mentions, etc.)
+ .lexxy-editor__content {
+ margin-block-start: 0.5lh;
+ }
+
+ .lexxy-code-language-picker {
+ border-radius: 99rem;
+ }
+
+ lexxy-table-tools {
+ font-size: var(--text-x-small);
+ }
+
+ [data-lexical-cursor] {
+ animation: blink 1s step-end infinite;
+ block-size: 1lh;
+ border-inline-start: 1px solid currentColor;
+ line-height: inherit;
+ margin-block: 1em;
+ }
+
+ /* Content
/* ------------------------------------------------------------------------ */
- .lexxy-prompt-menu {
- --lexxy-prompt-avatar-size: 24px;
- --lexxy-prompt-min-width: 20ch;
- --lexxy-prompt-padding: 0.5ch;
+ .lexxy-content {
+ --lexxy-content-margin: 0.5lh;
- background-color: var(--color-canvas);
- border-radius: calc(var(--lexxy-prompt-padding) * 2);
- box-shadow: var(--shadow);
- color: var(--color-ink);
- font-family: var(--font-sans);
- font-size: var(--text-small);
- list-style: none;
- margin: 0;
- max-height: 200px;
- min-inline-size: var(--lexxy-prompt-min-width);
- overflow: auto;
- padding: var(--lexxy-prompt-padding);
- visibility: hidden;
- z-index: var(--z-popup);
- }
+ color: currentColor;
- .lexxy-prompt-menu--visible {
- visibility: initial;
- }
-
- .lexxy-prompt-menu__item {
- align-items: center;
- border-radius: 0.5ch;
- cursor: pointer;
- display: flex;
- gap: var(--lexxy-prompt-padding);
- padding: var(--lexxy-prompt-padding);
- white-space: nowrap;
-
- &:hover {
- background-color: var(--color-ink-lightest);
+ h1, h2, h3, h4, h5, h6 {
+ font-weight: 800;
+ letter-spacing: -0.02ch;
+ line-height: 1.1;
+ overflow-wrap: break-word;
+ text-wrap: balance;
}
- &[aria-selected] {
- background-color: var(--color-selected);
- }
-
- img {
- block-size: var(--lexxy-prompt-avatar-size);
- border-radius: 50%;
- flex-shrink: 0;
- inline-size: var(--lexxy-prompt-avatar-size);
+ p:has(+ p) {
margin: 0;
}
- + & {
- margin-top: 2px;
+ ol, ul {
+ padding-inline-start: 1ch;
+ }
+
+ blockquote {
+ border-inline-start: 0.25em solid var(--color-ink-lighter);
+ padding-block: 0;
}
code {
- background-color: var(--color-terminal-text-light);
- border-radius: 0.25ch;
- font-size: 0.95em;
- padding-inline: 0.5ch;
+ background: var(--color-canvas);
+ border: 1px solid var(--color-ink-lighter);
+ }
+
+ .horizontal-divider {
+ padding-block: var(--lexxy-content-margin);
+ hr { margin: 0; }
+ }
+
+ hr {
+ border: 0;
+ border-block-end: 2px solid currentColor;
+ color: currentColor;
+ inline-size: 20%;
+ margin: calc(var(--lexxy-content-margin) * 2) 0;
+ }
+
+ table {
+ th, td {
+ font-size: var(--text-small);
+ padding-block: 0.75ch;
+ }
+ tr:not([data-action="delete"]) {
+ th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); }
+ td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); }
+ }
+ }
+
+ .attachment {
+ margin-inline: auto;
}
}
- /* Empty state */
- .lexxy-prompt-menu__item--empty {
- color: var(--color-ink-medium);
- padding: var(--lexxy-prompt-padding);
+ .attachment {
+ margin-inline: 0;
}
}
diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css
index c67d05d14..34259e9c6 100644
--- a/app/assets/stylesheets/native.css
+++ b/app/assets/stylesheets/native.css
@@ -66,6 +66,12 @@
translate: unset;
}
+ .card-perma--draft {
+ .card-perma__bg {
+ box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
+ }
+ }
+
/* Search
/* ------------------------------------------------------------------------ */
diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css
index fabea58a8..ca7a058f4 100644
--- a/app/assets/stylesheets/notifications.css
+++ b/app/assets/stylesheets/notifications.css
@@ -67,7 +67,7 @@
/* Help
/* ------------------------------------------------------------------------ */
.notifications-help {
- h2 {
+ h3 {
font-size: var(--text-medium);
margin: 0;
}
@@ -95,12 +95,12 @@
display: none;
.notifications--on & {
- display: inline;
+ display: revert;
}
}
.notifications__off-message {
- display: inline;
+ display: revert;
.notifications--on & {
display: none;
diff --git a/app/assets/stylesheets/performance-notice.css b/app/assets/stylesheets/performance-notice.css
new file mode 100644
index 000000000..a39bde2cd
--- /dev/null
+++ b/app/assets/stylesheets/performance-notice.css
@@ -0,0 +1,11 @@
+.performance-notice {
+ background: oklch(var(--lch-yellow-lightest));
+ border-radius: 1ch;
+ border: 1px solid oklch(var(--lch-yellow-light));
+ font-size: var(--text-small);
+ margin-block-end: 2ch;
+ margin-inline: auto;
+ max-inline-size: 60ch;
+ padding-inline: 2ch;
+ padding: 1ch;
+}
diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css
index 5019bf828..91aebcd64 100644
--- a/app/assets/stylesheets/qr-codes.css
+++ b/app/assets/stylesheets/qr-codes.css
@@ -1,6 +1,8 @@
@layer components {
.qr-code {
aspect-ratio: 1;
- block-size: 50dvh;
+ border-radius: 1ch;
+ inline-size: clamp(20ch, 50dvh, 70ch);
+ margin-block: var(--block-space);
}
}
diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css
index 64f2a5164..efd487f64 100644
--- a/app/assets/stylesheets/reactions.css
+++ b/app/assets/stylesheets/reactions.css
@@ -86,6 +86,10 @@
font-size: 0.6em;
inline-size: auto;
}
+
+ @media (any-hover: none) {
+ padding-inline-end: 0.12em;
+ }
}
.reaction--deleteable {
@@ -166,6 +170,11 @@
.reaction__cancel-btn {
--btn-size: 1.25rem;
--icon-size: var(--btn-size);
+
+ @media (any-hover: none) {
+ --btn-size: 2rem;
+ --icon-size: 90%;
+ }
}
.reaction__submit-btn {
@@ -219,6 +228,12 @@
&:hover {
scale: 1.3;
}
+
+ @media (any-hover: none) {
+ --btn-size: calc(1.6rem * 1.3);
+
+ font-size: 1.6rem;
+ }
}
}
}
diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css
deleted file mode 100644
index bf5328478..000000000
--- a/app/assets/stylesheets/rich-text-content.css
+++ /dev/null
@@ -1,469 +0,0 @@
-@layer components {
- :where(.rich-text-content) {
- --block-margin: 0.5lh;
-
- h1, h2, h3, h4, h5, h6 {
- display: block;
- font-weight: 800;
- letter-spacing: -0.02ch;
- line-height: 1.1;
- margin-block: 0 var(--block-margin);
- overflow-wrap: break-word;
- text-wrap: balance;
- }
-
- h1 { font-size: 2em; }
- h2 { font-size: 1.5em; }
- h3 { font-size: 1.17em; }
- h4 { font-size: 1em; }
- h5 { font-size: 0.83em; }
- h6 { font-size: 0.67em; }
-
- p, ul, ol, dl, blockquote, figure, .attachment {
- margin-block: 0 var(--block-margin);
-
- &:not(lexxy-editor &) {
- overflow-wrap: break-word;
- text-wrap: pretty;
- }
- }
-
- p:has(+ p) {
- margin: 0;
- }
-
- ol, ul {
- padding-inline-start: 3ch;
- }
-
- ol {
- list-style: decimal;
- }
-
- ul {
- list-style: disc;
- }
-
- li:has(li) {
- list-style: none;
-
- ol, ul {
- margin: 0;
- padding-inline-start: 2ch;
- }
- }
-
- b, strong, .lexxy-content__bold {
- font-weight: 700;
- }
-
- i, em, .lexxy-content__italic {
- font-style: italic;
- }
-
- s, .lexxy-content__strikethrough {
- text-decoration: line-through;
- }
-
- mark, .lexxy-content__highlight {
- background-color: transparent;
- color: inherit;
- }
-
- p, blockquote {
- letter-spacing: -0.005ch;
- }
-
- /* Avoid extra space due to empty paragraphs */
- p:empty {
- display: none;
- }
-
- blockquote {
- border-inline-start: 0.25em solid var(--color-ink-lighter);
- font-style: italic;
- margin: var(--block-margin) 0;
- padding-inline-start: 2ch;
- }
-
- img, video, embed, object {
- inline-size: auto;
- margin-inline: auto;
- max-block-size: 32rem;
- object-fit: contain;
- }
-
- /* Links should hug media contained within */
- a:has(img),
- a:has(video) {
- inline-size: fit-content;
- margin-inline: auto;
- }
-
- hr {
- color: currentColor;
- block-size: 0;
- border: none;
- border-block-end: 2px solid currentColor;
- inline-size: 20%;
- margin: calc(var(--block-margin) * 2) 0;
- }
-
- .horizontal-divider {
- margin: var(--block-margin) 0;
- padding-block: var(--block-margin);
- }
-
- /* Code */
- code, pre {
- background-color: var(--color-canvas);
- border: 1px solid var(--color-ink-lighter);
- border-radius: 0.3em;
- color: var(--color-ink);
- font-family: var(--font-mono);
- font-size: 0.85em;
- font-weight: 500;
- margin-block: var(--block-margin);
- padding: 0.1em 0.3em;
-
- &:is(pre),
- &[data-language] {
- border-radius: 0.5em;
- display: block;
- overflow-x: auto;
- padding: 0.5lh 2ch;
- tab-size: 2;
- text-wrap: nowrap;
- white-space: pre;
- word-break: break-word;
-
- /* Keywords and attributes */
- .code-token__attr,
- .token.atrule,
- .token.attr,
- .token.keyword {
- color: var(--color-code-token__att);
- }
-
- /* Constants, booleans, numbers, properties, tags */
- .code-token__property,
- .token.boolean,
- .token.constant,
- .token.deleted,
- .token.number,
- .token.property,
- .token.symbol,
- .token.tag {
- color: var(--color-code-token__property);
- }
-
- /* Strings, selectors, and built-in constructs */
- .code-token__selector,
- .token.builtin,
- .token.char,
- .token.inserted,
- .token.selector,
- .token.string {
- color: var(--color-code-token__selector);
- }
-
- /* Comments and meta information */
- .code-token__comment,
- .token.cdata,
- .token.comment,
- .token.doctype,
- .token.prolog {
- color: var(--color-code-token__comment);
- font-style: italic;
- }
-
- /* Operators and symbolic entities */
- .code-token__operator,
- .token.entity,
- .token.operator,
- .token.url {
- color: var(--color-code-token__operator);
- }
-
- /* Functions and class names */
- .code-token__function,
- .token.class,
- .token.class-name,
- .token.function {
- color: var(--color-code-token__function);
- }
-
- /* Variables, regex, namespaces, important */
- .code-token__variable,
- .token.important,
- .token.namespace,
- .token.regex,
- .token.variable {
- color: var(--color-code-token__variable);
- }
-
- /* Punctuation */
- .code-token__punctuation,
- .token.punctuation {
- color: var(--color-code-token__punctuation);
- }
- }
- }
-
- /* Tables */
- .lexxy-content__table-wrapper {
- margin: 0;
- margin-block: 1ch;
- overflow-x: auto;
- }
-
- table {
- border-collapse: collapse;
- border-spacing: 0;
- inline-size: calc(100% - 0.5ch);
- margin: 0.25ch;
-
- th,
- td {
- border: 1px solid var(--color-ink-light);
- padding: 0.5ch 1ch;
- text-align: start;
- word-break: normal;
-
- *:last-child {
- margin-block-end: 0;
- }
-
- *:not(pre, code) {
- word-break: normal;
- }
- }
-
- th,
- .lexxy-content__table-cell--header {
- background: var(--color-ink-lightest);
- }
-
- td {
- background: var(--color-canvas);
- }
- }
- }
-
- /* Attachments
- /* ------------------------------------------------------------------------ */
-
- .attachment {
- block-size: auto;
- display: block;
- inline-size: fit-content;
- position: relative;
- max-inline-size: 100%;
-
- progress {
- inline-size: 100%;
- margin: auto;
- }
- }
-
- .attachment__caption {
- color: color-mix(in oklch, var(--color-ink) 66%, transparent);
- font-size: var(--text-small);
-
- textarea {
- --input-border-radius: 0.3em;
- --input-border-size: 0;
- --input-padding: 0;
-
- background-color: var(--input-background, transparent);
- border: none;
- color: inherit;
- inline-size: 100%;
- max-inline-size: 100%;
- resize: none;
- text-align: center;
-
- &:focus {
- --focus-ring-size: 0;
- }
-
- @supports (field-sizing: content) {
- field-sizing: content;
- inline-size: auto;
- min-inline-size: 20ch;
- }
- }
- }
-
- .attachment__icon {
- aspect-ratio: 4/5;
- background-color: color-mix(var(--attachment-icon-color), transparent 90%);
- block-size: 2.5lh;
- border: 2px solid var(--attachment-icon-color);
- border-block-start-width: 1ch;
- border-radius: 0.5ch;
- box-sizing: border-box;
- color: var(--attachment-icon-color);
- display: grid;
- font-size: var(--text-small);
- font-weight: bold;
- inline-size: auto;
- padding-inline: 0.5ch;
- place-content: center;
- text-transform: uppercase;
- white-space: nowrap;
- }
-
- .attachment--preview {
- margin-inline: auto;
- text-align: center;
-
- img, video {
- block-size: auto;
- display: block;
- margin-inline: auto;
- max-inline-size: 100%;
- user-select: none;
- }
-
- > a {
- display: block;
- }
-
- .attachment__caption {
- column-gap: 0.5ch;
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- margin-block-start: 0.5ch;
- }
- }
-
- .attachment--file {
- --attachment-icon-color: var(--color-ink-medium);
-
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 1ch;
- inline-size: 100%;
- margin-inline: 0;
-
- .attachment__caption {
- display: grid;
- flex: 1;
- text-align: start;
- }
-
- .attachment__name {
- color: var(--color-ink);
- font-weight: bold;
- }
-
- /* Video attachments don't have an identifiable class, but we need to
- * make sure the caption is always below the video */
- &:has(video) {
- .attachment__caption {
- flex: none;
- inline-size: 100%;
- }
- }
- }
-
- .attachment--psd,
- .attachment--key,
- .attachment--sketch,
- .attachment--ai,
- .attachment--eps,
- .attachment--indd,
- .attachment--svg,
- .attachment--ppt,
- .attachment--pptx {
- --attachment-icon-color: oklch(var(--lch-red-medium));
- }
-
- .attachment--css,
- .attachment--crash,
- .attachment--php,
- .attachment--json,
- .attachment--htm,
- .attachment--html,
- .attachment--rb,
- .attachment--erb,
- .attachment--ts,
- .attachment--js {
- --attachment-icon-color: oklch(var(--lch-purple-medium));
- }
-
- .attachment--txt,
- .attachment--pages,
- .attachment--rtf,
- .attachment--md,
- .attachment--doc,
- .attachment--docx {
- --attachment-icon-color: oklch(var(--lch-blue-medium));
- }
-
- .attachment--csv &,
- .attachment--numbers &,
- .attachment--xls &,
- .attachment--xlsx & {
- --attachment-icon-color: oklch(var(--lch-green-medium));
- }
-
- .attachment__link {
- color: var(--color-link);
- text-decoration: underline;
- }
-
- /* Custom attachments such as mentions, etc. */
- action-text-attachment[content-type^='application/vnd.actiontext'] {
- --attachment-bg-color: transparent;
- --attachment-image-size: 1em;
- --attachment-text-color: currentColor;
-
- align-items: center;
- background: var(--attachment-bg-color);
- border-radius: 99rem;
- box-shadow:
- -0.25ch 0 0 var(--attachment-bg-color),
- 0.5ch 0 0 var(--attachment-bg-color);
- color: var(--attachment-text-color);
- display: inline-flex;
- gap: 0.25ch;
- margin: 0;
- padding: 0;
- position: relative;
- vertical-align: bottom;
- white-space: normal;
-
- lexxy-editor & {
- cursor: pointer;
- }
-
- img {
- block-size: var(--attachment-image-size);
- border-radius: 50%;
- inline-size: var(--attachment-image-size);
- }
-
- &.node--selected {
- --attachment-bg-color: oklch(var(--lch-blue-dark));
- --attachment-text-color: var(--color-ink-inverted);
- }
- }
-
- action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
- img {
- object-fit: cover;
- }
- }
-
- [data-lexical-cursor] {
- animation: blink 1s step-end infinite;
- block-size: 1lh;
- border-inline-start: 1px solid currentColor;
- line-height: inherit;
- margin-block: 1em;
- }
-}
diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css
index 0e39c33ee..2a2d0c9cd 100644
--- a/app/assets/stylesheets/settings.css
+++ b/app/assets/stylesheets/settings.css
@@ -58,6 +58,14 @@
/* Users
/* ------------------------------------------------------------------------ */
+ .settings__section--users {
+ @media (min-width: 640px) {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ }
+ }
.settings__user-filter {
--btn-size: 3.5ch;
--avatar-size: var(--btn-size);
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 76dedfe12..53837034c 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -32,6 +32,7 @@
.txt-link { color: var(--color-link); text-decoration: underline; }
.font-weight-normal { font-weight: normal; }
+ .font-weight-semibold { font-weight: 600; }
.font-weight-bold { font-weight: bold; }
.font-weight-black { font-weight: 900; }
diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb
index 95acc7511..75fdbac19 100644
--- a/app/controllers/cards/readings_controller.rb
+++ b/app/controllers/cards/readings_controller.rb
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
- @notifications = @card.read_by(Current.user)
+ @notification = @card.read_by(Current.user)
record_board_access
end
def destroy
- @notifications = @card.unread_by(Current.user)
+ @notification = @card.unread_by(Current.user)
record_board_access
end
diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb
index c2bac662a..0adc0e17b 100644
--- a/app/controllers/notifications/trays_controller.rb
+++ b/app/controllers/notifications/trays_controller.rb
@@ -1,6 +1,6 @@
class Notifications::TraysController < ApplicationController
def show
- @notifications = Current.user.notifications.preloaded.unread.ordered.limit(100)
+ @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
# can stay the same when reading old notifications.
diff --git a/app/controllers/qr_codes_controller.rb b/app/controllers/qr_codes_controller.rb
index aa8ca8334..a49809876 100644
--- a/app/controllers/qr_codes_controller.rb
+++ b/app/controllers/qr_codes_controller.rb
@@ -6,7 +6,7 @@ class QrCodesController < ApplicationController
qr_code_svg = RQRCode::QRCode
.new(QrCodeLink.from_signed(params[:id]).url)
- .as_svg(viewbox: true, fill: :white, color: :black)
+ .as_svg(viewbox: true, fill: :white, color: :black, offset: 16)
render svg: qr_code_svg
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 5f12b9259..eae2f3665 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -9,4 +9,12 @@ module BoardsHelper
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
end
end
+
+ def bridged_button_to_board(board)
+ link_to "Go to #{board.name}", board, hidden: true, data: {
+ bridge__buttons_target: "button",
+ bridge_icon_url: bridge_icon("board"),
+ bridge_title: "Go to #{board.name}"
+ }
+ end
end
diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb
index b3e98b30e..9938907e1 100644
--- a/app/helpers/columns_helper.rb
+++ b/app/helpers/columns_helper.rb
@@ -5,7 +5,7 @@ module ColumnsHelper
card_triage_path(card, column_id: column),
method: :post,
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
- disabled: column == card.column,
+ disabled: column == card.column && card.open?,
style: "--column-color: #{column.color}",
form_class: "flex gap-half",
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb
index a3d0bbab3..7b65d34cf 100644
--- a/app/helpers/html_helper.rb
+++ b/app/helpers/html_helper.rb
@@ -2,4 +2,10 @@ module HtmlHelper
def format_html(html)
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
end
+
+ def card_html_title(card)
+ return card.title if card.title.blank?
+
+ ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '\1').html_safe
+ end
end
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 355f60550..f376d15c1 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -29,9 +29,7 @@ module NotificationsHelper
def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
navigable_list_target: "item",
- notifications_tray_target: "notification",
- card_id: notification.card.id,
- timestamp: notification.created_at.to_i
+ card_id: notification.card.id
} do
link_to(notification,
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
@@ -58,7 +56,7 @@ module NotificationsHelper
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
form: { data: { controller: "form" } } do
concat(icon_tag("remove"))
- concat(tag.span("1", class: "badge-count", data: { group_count: "" }))
+ concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
end
end
end
diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb
index c1bc2232a..c580b4507 100644
--- a/app/helpers/rich_text_helper.rb
+++ b/app/helpers/rich_text_helper.rb
@@ -15,11 +15,7 @@ module RichTextHelper
content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true
end
- def code_language_picker
- content_tag "lexxy-code-language-picker"
- end
-
def general_prompts(board)
- safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ])
+ safe_join([ mentions_prompt(board), cards_prompt ])
end
end
diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js
index 939fe8441..35c5efedf 100644
--- a/app/javascript/controllers/collapsible_columns_controller.js
+++ b/app/javascript/controllers/collapsible_columns_controller.js
@@ -1,5 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame, debounce } from "helpers/timing_helpers";
+import { isNative } from "helpers/platform_helpers";
export default class extends Controller {
static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ]
@@ -49,7 +50,7 @@ export default class extends Controller {
focusOnColumn({ target }) {
if (this.#isDesktop && this.#isCollapsed(target)) {
this.#collapseAllExcept(target)
- this.#expand(target)
+ this.#expand({ column: target })
}
}
@@ -74,7 +75,7 @@ export default class extends Controller {
this.#collapseAllExcept(column)
if (this.#isCollapsed(column)) {
- this.#expand(column)
+ this.#expand({ column })
} else {
this.#collapse(column)
}
@@ -103,7 +104,7 @@ export default class extends Controller {
localStorage.removeItem(key)
}
- #expand(column, saveState = true) {
+ #expand({ column, saveState = true, scrollBehavior = "smooth" }) {
this.#buttonFor(column)?.setAttribute("aria-expanded", "true")
column.classList.remove(this.collapsedClass)
column.classList.add(this.expandedClass)
@@ -114,7 +115,7 @@ export default class extends Controller {
}
if (window.matchMedia('(max-width: 639px)').matches) {
- column.scrollIntoView({ behavior: "smooth", inline: "center" })
+ column.scrollIntoView({ behavior: scrollBehavior, inline: "center" })
}
}
@@ -132,7 +133,7 @@ export default class extends Controller {
const key = this.#localStorageKeyFor(column)
if (localStorage.getItem(key)) {
this.#collapseAllExcept(column)
- this.#expand(column)
+ this.#expand({ column, scrollBehavior: isNative() ? "instant" : "smooth" })
}
}
@@ -168,7 +169,7 @@ export default class extends Controller {
}
async #handleDesktopMode() {
- this.#expand(this.maybeColumnTarget, false)
+ this.#expand({ column: this.maybeColumnTarget, saveState: false })
this.#maybeButton.setAttribute("disabled", true)
}
@@ -181,7 +182,7 @@ export default class extends Controller {
this.#collapseAllExcept(expandedColumn)
} else {
this.#collapseAllExcept(this.maybeColumnTarget)
- this.#expand(this.maybeColumnTarget, false)
+ this.#expand({ column: this.maybeColumnTarget, saveState: false })
}
}
diff --git a/app/javascript/controllers/expandable_on_native_controller.js b/app/javascript/controllers/expandable_on_native_controller.js
index a58ddfa9a..913437809 100644
--- a/app/javascript/controllers/expandable_on_native_controller.js
+++ b/app/javascript/controllers/expandable_on_native_controller.js
@@ -9,8 +9,6 @@ export default class extends Controller {
static values = { autoExpandSelector: String }
connect() {
- if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) {
- this.element.open = true
- }
+ this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)
}
}
diff --git a/app/javascript/controllers/notifications_tray_controller.js b/app/javascript/controllers/notifications_tray_controller.js
deleted file mode 100644
index 12077425b..000000000
--- a/app/javascript/controllers/notifications_tray_controller.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = [ "notification", "hiddenNotifications" ]
- static classes = [ "grouped" ]
-
- connect() {
- this.group()
- }
-
- group() {
- const notificationsByCardId = this.#groupNotificationsByCardId()
-
- for (const cardId in notificationsByCardId) {
- const notifications = notificationsByCardId[cardId]
- if (notifications.length > 1) {
- this.#renderGroup(notifications)
- }
- }
-
- this.grouped = true
- }
-
- notificationTargetConnected(notification) {
- if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
- this.#groupNotification(notification)
- }
- }
-
- #groupNotificationsByCardId() {
- const notificationsByCardId = {}
-
- this.notificationTargets.forEach(notification => {
- const cardId = notification.dataset.cardId
- notificationsByCardId[cardId] ||= []
- notificationsByCardId[cardId].push(notification)
- })
-
- return notificationsByCardId
- }
-
- #groupNotification(notification) {
- const groupedNotifications = this.#groupedNotificationsFor(notification)
-
- if (groupedNotifications.length > 1) {
- this.#renderGroup(groupedNotifications)
- }
- }
-
- #groupedNotificationsFor(notification) {
- const cardId = notification.dataset.cardId
- return this.notificationTargets
- .filter(notification => notification.dataset.cardId === cardId)
- }
-
- #renderGroup(groupedNotifications) {
- groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
-
- this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
- groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
- }
-
- #showAsGrouped(notification, size) {
- notification.classList.add(this.groupedClass)
- this.#showGroupedNotification(notification)
- this.#setGroupCount(notification, size)
- }
-
- #hideInGroup(notification) {
- this.#hideGroupedNotification(notification)
- this.#setGroupCount(notification, "")
- }
-
- // We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
- // tray element indexes doesn't get messed up with the child positions changing.
- #showGroupedNotification(notification) {
- if (notification.parentElement === this.hiddenNotificationsTarget) {
- this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
- }
- }
-
- #hideGroupedNotification(notification) {
- this.hiddenNotificationsTarget.appendChild(notification)
- }
-
- #setGroupCount(notification, count) {
- notification.querySelector("[data-group-count]").textContent = count
- }
-}
diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb
index c626ddb2e..3ab25e481 100644
--- a/app/mailers/import_mailer.rb
+++ b/app/mailers/import_mailer.rb
@@ -4,7 +4,8 @@ class ImportMailer < ApplicationMailer
mail to: identity.email_address, subject: "Your Fizzy account import is done"
end
- def failed(identity)
- mail to: identity.email_address, subject: "Your Fizzy account import failed"
+ def failed(import)
+ @import = import
+ mail to: import.identity.email_address, subject: "Your Fizzy account import failed"
end
end
diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb
index 89fea208a..d0998fa23 100644
--- a/app/mailers/notification/bundle_mailer.rb
+++ b/app/mailers/notification/bundle_mailer.rb
@@ -7,10 +7,14 @@ class Notification::BundleMailer < ApplicationMailer
@user = bundle.user
@bundle = bundle
@notifications = bundle.notifications
+ .preload(:card, :creator, source: [ :board, :creator ])
+ .reject { |n| n.source.nil? || n.card.nil? }
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
- mail \
- to: bundle.user.identity.email_address,
- subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
+ if @notifications.any?
+ mail \
+ to: bundle.user.identity.email_address,
+ subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
+ end
end
end
diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb
index ec2f4e17f..fa1a23cb9 100644
--- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb
+++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb
@@ -84,9 +84,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
gid = GlobalID.parse(node["gid"])
if gid
- record = gid.find
- node["sgid"] = record.attachable_sgid
- node.remove_attribute("gid")
+ record = begin
+ gid.find
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ if record&.account_id == account.id
+ node["sgid"] = record.attachable_sgid
+ node.remove_attribute("gid")
+ end
end
end
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
index b7955932a..8eeafbe10 100644
--- a/app/models/account/data_transfer/record_set.rb
+++ b/app/models/account/data_transfer/record_set.rb
@@ -1,5 +1,6 @@
class Account::DataTransfer::RecordSet
class IntegrityError < StandardError; end
+ class ConflictError < IntegrityError; end
IMPORT_BATCH_SIZE = 100
@@ -93,7 +94,7 @@ class Account::DataTransfer::RecordSet
end
if model.exists?(id: data["id"])
- raise IntegrityError, "#{model} record with ID #{data['id']} already exists"
+ raise ConflictError, "#{model} record with ID #{data['id']} already exists"
end
check_associations_dont_exist(data)
@@ -118,7 +119,7 @@ class Account::DataTransfer::RecordSet
end
if associated_class.exists?(id: associated_id)
- raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}"
+ raise ConflictError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}"
end
end
diff --git a/app/models/account/import.rb b/app/models/account/import.rb
index 814e6b00c..14ee376ea 100644
--- a/app/models/account/import.rb
+++ b/app/models/account/import.rb
@@ -7,6 +7,7 @@ class Account::Import < ApplicationRecord
has_one_attached :file
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
+ enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false
scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) }
@@ -26,6 +27,12 @@ class Account::Import < ApplicationRecord
record_set.check(from: zip, start: last_id, callback: callback)
end
end
+ rescue Account::DataTransfer::RecordSet::ConflictError => e
+ mark_as_failed(:conflict)
+ raise e
+ rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e
+ mark_as_failed(:invalid_export)
+ raise e
rescue => e
mark_as_failed
raise e
@@ -44,6 +51,12 @@ class Account::Import < ApplicationRecord
reconcile_account_storage
mark_completed
+ rescue Account::DataTransfer::RecordSet::ConflictError => e
+ mark_as_failed(:conflict)
+ raise e
+ rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e
+ mark_as_failed(:invalid_export)
+ raise e
rescue => e
mark_as_failed
raise e
@@ -60,9 +73,9 @@ class Account::Import < ApplicationRecord
ImportMailer.completed(identity, account).deliver_later
end
- def mark_as_failed
- failed!
- ImportMailer.failed(identity).deliver_later
+ def mark_as_failed(failure_reason = nil)
+ update!(status: :failed, failure_reason: failure_reason)
+ ImportMailer.failed(self).deliver_later
end
def add_importer_to_all_access_boards
diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb
index 9c272dd75..aec8c18dd 100644
--- a/app/models/card/readable.rb
+++ b/app/models/card/readable.rb
@@ -2,15 +2,11 @@ module Card::Readable
extend ActiveSupport::Concern
def read_by(user)
- notifications_for(user).tap do |notifications|
- notifications.each(&:read)
- end
+ user.notifications.find_by(card: self)&.tap(&:read)
end
def unread_by(user)
- all_notifications_for(user).tap do |notifications|
- notifications.each(&:unread)
- end
+ user.notifications.find_by(card: self)&.tap(&:unread)
end
def remove_inaccessible_notifications
@@ -25,18 +21,6 @@ module Card::Readable
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
- def notifications_for(user)
- scope = user.notifications.unread
- scope.where(source: event_notification_sources)
- .or(scope.where(source: mention_notification_sources))
- end
-
- def all_notifications_for(user)
- scope = user.notifications
- scope.where(source: event_notification_sources)
- .or(scope.where(source: mention_notification_sources))
- end
-
def event_notification_sources
events.or(comment_creation_events)
end
diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb
index e497bc3fe..e7bfdc56a 100644
--- a/app/models/concerns/push_notifiable.rb
+++ b/app/models/concerns/push_notifiable.rb
@@ -3,6 +3,7 @@ module PushNotifiable
included do
after_create_commit :push_notification_later
+ after_update_commit :push_notification_later, if: :source_id_previously_changed?
end
private
diff --git a/app/models/event/description.rb b/app/models/event/description.rb
index fd7a66ae1..454e636bb 100644
--- a/app/models/event/description.rb
+++ b/app/models/event/description.rb
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
- to_sentence(creator_name, quoted(card.title))
+ to_sentence(creator_name, quoted(card.title)).html_safe
end
private
@@ -38,11 +38,11 @@ class Event::Description
end
def creator_name
- h(event.creator.name)
+ h event.creator.name
end
def quoted(text)
- %("#{h text}")
+ h %("#{text}")
end
def card
@@ -86,27 +86,43 @@ class Event::Description
if event.assignees.include?(user)
"#{creator} will handle #{card_title}"
else
- "#{creator} assigned #{h event.assignees.pluck(:name).to_sentence} to #{card_title}"
+ "#{creator} assigned #{assignee_names} to #{card_title}"
end
end
def unassigned_sentence(creator, card_title)
- assignees_text = event.assignees.include?(user) ? "yourself" : event.assignees.pluck(:name).to_sentence
- "#{creator} unassigned #{h(assignees_text)} from #{card_title}"
+ "#{creator} unassigned #{unassigned_names} from #{card_title}"
end
def renamed_sentence(creator, card_title)
- old_title = event.particulars.dig("particulars", "old_title")
- %(#{creator} renamed #{card_title} (was: "#{h old_title}"))
+ %(#{creator} renamed #{card_title} (was: "#{old_title}"))
end
def moved_sentence(creator, card_title)
- new_location = event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")
- %(#{creator} moved #{card_title} to "#{h new_location}")
+ %(#{creator} moved #{card_title} to "#{new_location}")
end
def triaged_sentence(creator, card_title)
- column = event.particulars.dig("particulars", "column")
- %(#{creator} moved #{card_title} to "#{h column}")
+ %(#{creator} moved #{card_title} to "#{column}")
+ end
+
+ def assignee_names
+ h event.assignees.pluck(:name).to_sentence
+ end
+
+ def unassigned_names
+ h(event.assignees.include?(user) ? "yourself" : assignee_names)
+ end
+
+ def old_title
+ h event.particulars.dig("particulars", "old_title")
+ end
+
+ def new_location
+ h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection"))
+ end
+
+ def column
+ h event.particulars.dig("particulars", "column")
end
end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 545f6ab75..3d7df5885 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
belongs_to :user
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
+ belongs_to :card
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
- scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
+ scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
+ scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
- after_create_commit :broadcast_unread
- after_destroy_commit :broadcast_read
+ before_validation :set_card
after_create :bundle
+ after_update :bundle, if: :source_id_previously_changed?
- scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
+ after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
+ after_update_commit -> { broadcast_update }
+ after_destroy_commit -> { broadcast_remove_to user, :notifications }
delegate :notifiable_target, to: :source
- delegate :card, to: :source
- def self.read_all
- all.each { |notification| notification.read }
+ class << self
+ def read_all
+ all.each(&:read)
+ end
+
+ def unread_all
+ all.each(&:unread)
+ end
end
def read
- update!(read_at: Time.current)
- broadcast_read
+ update!(read_at: Time.current, unread_count: 0)
end
def unread
- update!(read_at: nil)
- broadcast_unread
+ update!(read_at: nil, unread_count: 1)
end
def read?
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
end
private
- def broadcast_unread
- broadcast_prepend_later_to user, :notifications, target: "notifications"
- end
-
- def broadcast_read
- broadcast_remove_to user, :notifications
+ def set_card
+ self.card = source.card
end
def bundle
user.bundle(self) if user.settings.bundling_emails?
end
+
+ def broadcast_update
+ if read?
+ broadcast_remove_to(user, :notifications)
+ else
+ broadcast_prepend_later_to(user, :notifications, target: "notifications")
+ end
+ end
end
diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb
index fb57a4fd0..1eadb3c9e 100644
--- a/app/models/notification/bundle.rb
+++ b/app/models/notification/bundle.rb
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
enum :status, %i[ pending processing delivered ]
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
- scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
+ scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
scope :overlapping_with, ->(other_bundle) do
where(
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
end
def notifications
- user.notifications.where(created_at: window).unread
+ user.notifications.where(updated_at: window).unread
end
def deliver
diff --git a/app/models/notifier.rb b/app/models/notifier.rb
index 24c1b3b4e..f6f356fa4 100644
--- a/app/models/notifier.rb
+++ b/app/models/notifier.rb
@@ -16,7 +16,23 @@ class Notifier
if should_notify?
# Processing recipients in order avoids deadlocks if notifications overlap.
recipients.sort_by(&:id).map do |recipient|
- Notification.create! user: recipient, source: source, creator: creator
+ notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n|
+ n.source = source
+ n.creator = creator
+ n.unread_count = 1
+ end
+
+ unless notification.previously_new_record?
+ # Always include source_type in the update to prevent a race condition between
+ # concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking
+ # may skip source_type when it hasn't changed from the stale in-memory value,
+ # even though another job has since modified it in the database, leaving
+ # source_type and source_id mismatched.
+ notification.source_type_will_change!
+ notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1)
+ end
+
+ notification
end
end
end
diff --git a/app/models/user/notifiable.rb b/app/models/user/notifiable.rb
index f6788aaac..7598f0e0f 100644
--- a/app/models/user/notifiable.rb
+++ b/app/models/user/notifiable.rb
@@ -25,12 +25,12 @@ module User::Notifiable
def expand_pending_bundle_for(notification)
pending = notification_bundles.pending.last
- if pending.present? && notification.created_at < pending.starts_at
- pending.update!(starts_at: notification.created_at) # expand the window to include this notification
+ if pending.present? && notification.updated_at < pending.starts_at
+ pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
end
end
def create_bundle_for(notification)
- notification_bundles.create!(starts_at: notification.created_at)
+ notification_bundles.create!(starts_at: notification.updated_at)
end
end
diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb
index 50a0cd560..40dab42f0 100644
--- a/app/models/webhook/delivery.rb
+++ b/app/models/webhook/delivery.rb
@@ -22,8 +22,8 @@ class Webhook::Delivery < ApplicationRecord
after_create_commit :deliver_later
- def self.cleanup
- stale.delete_all
+ def self.cleanup(batch_size: 500, pause: 0.1)
+ sleep pause until stale.limit(batch_size).delete_all.zero?
end
def deliver_later
diff --git a/app/views/account/imports/new.html.erb b/app/views/account/imports/new.html.erb
index fd837a7b6..b4e12e94b 100644
--- a/app/views/account/imports/new.html.erb
+++ b/app/views/account/imports/new.html.erb
@@ -1,9 +1,15 @@
<% @page_title = "Import an account" %>
-
-
If you run into issues or would like assistance, just send us an email.
Your import is in progress. This may take a while for large accounts.
+Your import has completed successfully!
- <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> +Your import failed.
-This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
- <%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %> +