Merge branch 'main' into fix-blurred-nav-menu-in-safari

This commit is contained in:
Alexander Zaytsev
2026-02-17 14:44:39 +01:00
128 changed files with 1788 additions and 4038 deletions
@@ -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
+3 -3
View File
@@ -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 }}
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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)
+1
View File
@@ -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"
+11 -9
View File
@@ -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
+207
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -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 */
+1 -1
View File
@@ -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 {
+3 -2
View File
@@ -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);
}
}
+108 -8
View File
@@ -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;
}
}
}
+17 -1
View File
@@ -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),
+12 -1
View File
@@ -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);
}
}
+11 -6
View File
@@ -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 {
+33
View File
@@ -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%;
}
}
}
+141 -469
View File
@@ -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;
}
}
+6
View File
@@ -66,6 +66,12 @@
translate: unset;
}
.card-perma--draft {
.card-perma__bg {
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
}
}
/* Search
/* ------------------------------------------------------------------------ */
+3 -3
View File
@@ -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;
@@ -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;
}
+3 -1
View File
@@ -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);
}
}
+15
View File
@@ -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;
}
}
}
}
@@ -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;
}
}
+8
View File
@@ -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);
+1
View File
@@ -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; }
+2 -2
View File
@@ -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
@@ -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.
+1 -1
View File
@@ -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
+8
View File
@@ -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
+1 -1
View File
@@ -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 }
+6
View File
@@ -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(/`([^`]+)`/, '<code>\1</code>').html_safe
end
end
+2 -4
View File
@@ -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
+1 -5
View File
@@ -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
@@ -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 })
}
}
@@ -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)
}
}
@@ -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
}
}
+3 -2
View File
@@ -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
+7 -3
View File
@@ -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
@@ -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
@@ -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
+16 -3
View File
@@ -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
+2 -18
View File
@@ -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
+1
View File
@@ -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
+28 -12
View File
@@ -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
+28 -17
View File
@@ -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
+2 -2
View File
@@ -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
+17 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+12 -4
View File
@@ -1,9 +1,15 @@
<% @page_title = "Import an account" %>
<div class="panel panel--centered flex flex-column gap">
<div class="panel panel--centered flex flex-column gap" style="--panel-size: 54ch;">
<header>
<h1 class="txt-x-large font-weight-black txt-tight-lines margin-block-end-half">Import a Fizzy account</h1>
<div>Create an account using data from a Fizzy export. Upload the exported .zip file below.</div>
<h1 class="txt-x-large font-weight-black txt-tight-lines margin-block-end-none">Import a Fizzy account</h1>
<% if Fizzy.saas? %>
<div class="font-weight-semibold margin-block-end">Running Fizzy on your own server and want us to host on <a class="txt-ink txt-underline" href="https://fizzy.do" target="_blank" rel="noopener noreferrer">fizzy.do</a> instead?</div>
<div>Export your self-hosted account, then upload the .zip file below.</div>
<% else %>
<div class="font-weight-semibold margin-block-end">Ready to host Fizzy on your own server?</div>
<div>Export your <a class="txt-ink txt-underline" href="https://fizzy.do" target="_blank" rel="noopener noreferrer">fizzy.do</a> account, then upload the .zip file below.</div>
<% end %>
</header>
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %>
@@ -17,6 +23,8 @@
<span>Start Import →</span>
</button>
<% end %>
<p class="txt-small">If you run into issues or would like assistance, just <a href="mailto:support@fizzy.do" class="txt-ink txt-underline">send us an email</a>.</p>
</div>
<% content_for :footer do %>
+19 -7
View File
@@ -2,19 +2,31 @@
<%= turbo_stream_from @import %>
<div class="panel panel--centered flex flex-column gap-half">
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 54ch;">
<h1 class="txt-x-large font-weight-black margin-block-end">Import status</h1>
<% case @import.status %>
<% when "pending", "processing" %>
<p class="txt-medium">Your import is in progress. This may take a while for large accounts.</p>
<div class="import-status">
Your import is in progress. This may take a while for large accounts.
</div>
<% when "completed" %>
<p class="txt-medium txt-positive">Your import has completed successfully!</p>
<%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %>
<div class="import-status import-status--success">
<div><strong>Your import was successful!</strong></div>
<%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %>
</div>
<% when "failed" %>
<p class="txt-medium txt-negative">Your import failed.</p>
<p class="txt-small">This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</p>
<%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %>
<div class="import-status import-status--error">
<div><strong>Import failed</strong></div>
<% if @import.failed_due_to_conflict? %>
<div>The account youre trying to import already exists. Make sure youre importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.</div>
<% elsif @import.failed_due_to_invalid_export? %>
<div>The .zip file you uploaded doesnt look like a Fizzy account export.</div>
<% else %>
<div>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</div>
<% end %>
<%= link_to "Try again", new_account_import_path, class: "btn" %>
</div>
<% end %>
</div>
+4 -2
View File
@@ -39,8 +39,10 @@
<span>Get QR code</span>
<% end %>
<dialog class="dialog panel shadow" data-dialog-target="dialog">
<p class="margin-none-block-start"><strong>Scan this code with the camera on your mobile device</strong></p>
<dialog class="dialog panel shadow" data-dialog-target="dialog" style="--panel-size: 50ch;">
<p class="margin-none-block-start txt-balance">
<strong>Scan this code to join <%= Current.account.name %>:</strong>
</p>
<%= qr_code_image(url) %>
+1 -1
View File
@@ -10,7 +10,7 @@
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
<h2 class="txt-large">Export all account data</h2>
<p>This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.</p>
<p>Well email you a link to download the file when its ready. The link will expire after 24 hours.</p>
<p>Well email you a link to download the file when its ready. The link will expire after 24 hours.</p>
<div class="flex gap justify-center">
<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
+2 -2
View File
@@ -1,9 +1,9 @@
<section class="settings__section">
<section class="settings__section settings__section--users">
<header>
<h2 class="divider">People on this account</h2>
</header>
<%= tag.div class: "flex flex-column gap settings__user-filter", data: {
<%= tag.div class: "settings__user-filter flex flex-column gap", data: {
controller: "filter navigable-list",
action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
navigable_list_focus_on_selection_value: true,
+1 -1
View File
@@ -30,7 +30,7 @@
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
<%= form.rich_textarea :public_description, class: "lexxy-content txt-small",
placeholder: "Add a public note about this board…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" },
readonly: !Current.user.can_administer_board?(@board) %>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="panel panel--centered">
<%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names cant be blank" } %>
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names cant be blank" } %>
<button type="submit" class="btn btn--link center" data-bridge--form-target="submit">
<span>Create board</span>
+1
View File
@@ -3,6 +3,7 @@ json.cache! card do
json.description card.description.to_plain_text
json.description_html card.description.to_s
json.image_url card.image.presence && url_for(card.image)
json.has_attachments card.has_attachments?
json.tags card.tags.pluck(:title).sort
+1 -1
View File
@@ -32,7 +32,7 @@
<% end %>
</div>
<div class="comment__body rich-text-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
<div class="comment__body lexxy-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
<%= comment.body %>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
</figure>
<div class="comment__content flex-inline flex-column full-width">
<div class="comment__body rich-text-content" data-turbo-permanent>
<div class="comment__body lexxy-content" data-turbo-permanent>
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
+1 -1
View File
@@ -5,7 +5,7 @@
</figure>
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__body rich-text-content" data-turbo-permanent>
<div class="comment__body lexxy-content" data-turbo-permanent>
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
@@ -1,6 +1,6 @@
<div class="flex gap-half">
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless",
data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %>
data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %>
<%= icon_tag "pencil", class: "icon--mobile-only" %>
<span>Edit</span>
<kbd class="txt-x-small hide-on-touch">e</kbd>
+1 -1
View File
@@ -20,7 +20,7 @@
<% end %>
</h1>
<%= form.rich_textarea :description, class: "card__description rich-text-content",
<%= form.rich_textarea :description, class: "card__description lexxy-content",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %>
<%= general_prompts(card.board) %>
@@ -1,9 +1,9 @@
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
<%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>
</h1>
<% unless card.description.blank? %>
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
<div class="card__description lexxy-content" data-controller="syntax-highlight retarget-links">
<%= card.description %>
</div>
<% end %>
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h3 class="card__title overflow-line-clamp">
<%= card.title %>
<%= card_html_title(card) %>
</h3>
</div>
@@ -17,7 +17,7 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h1 class="card__title overflow-line-clamp">
<%= card.title %>
<%= card_html_title(card) %>
</h1>
</div>
+1 -1
View File
@@ -15,7 +15,7 @@
</div>
<% if card.tags.any? %>
<div class="min-width overflow-ellipsis">
<div class="card__tags-list min-width">
<% card.tags.each_with_index do |tag, index| %>
<%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]),
class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %>
+1 -1
View File
@@ -1,4 +1,4 @@
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<section id="<%= dom_id(card, :card_container) %>" class="card-perma card-perma--draft" style="--card-color: <%= card.color %>;">
<% cache card do %>
<div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/image", card: card %>
+1 -1
View File
@@ -16,7 +16,7 @@
<% end %>
</h1>
<%= form.rich_textarea :description, class: "card__description rich-text-content",
<%= form.rich_textarea :description, class: "card__description lexxy-content",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
<%= general_prompts(@card.board) %>
@@ -1,3 +1 @@
<% @notifications.each do |notification| %>
<%= turbo_stream.remove notification %>
<% end %>
<%= turbo_stream.remove @notification if @notification %>
+1
View File
@@ -9,6 +9,7 @@
<div class="header__actions header__actions--start">
<%= link_back_to_board(@card.board) %>
</div>
<%= bridged_button_to_board(@card.board) %>
<% end %>
<%= turbo_stream_from @card %>
+1
View File
@@ -1,4 +1,5 @@
<%= tag.details class: "expandable-on-native",
open: true,
data: {
controller: "expandable-on-native",
expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %>
+23 -9
View File
@@ -9,11 +9,12 @@
}
body {
color: #17233C;
font-family: system-ui, sans-serif;
font-size: 16px;
line-height: 1.3;
margin: 0;
padding: 1rem;
padding: 16px;
}
img {
@@ -72,13 +73,13 @@
.footer {
border-top: 1px solid #ccc;
margin-top: 3em;
margin-top: 32px;
padding-top: 1em;
}
.notification {
margin-top: 1rem;
margin-bottom: 1rem;
margin-top: 16px;
margin-bottom: 16px;
}
.notification__author {
@@ -88,12 +89,25 @@
}
.notification__board {
font-size: 0.8em;
font-weight: normal;
margin-bottom: 0;
font-size: 1.4em;
font-weight: 900;
margin-bottom: 16px;
margin-top: 0;
opacity: 0.66;
text-transform: uppercase;
}
.notification__board a {
color: #17233C;
text-decoration: none;
}
.notification__board a:hover {
text-decoration: underline;
}
.notification__board-separator {
border: none;
border-top: 1px solid #ddd;
margin: 2em 0 1em;
}
.notification__details {
@@ -1,8 +1,11 @@
<p class="subtitle">Unfortunately, we couldn't import your Fizzy account.</p>
<p>
This may be due to corrupted export data or a conflict with existing data.
Please try again with a fresh export, or reach out for help if the problem persists.
</p>
<% if @import.failed_due_to_conflict? %>
<p>It looks like the account you are trying to import already exists.</p>
<% elsif @import.failed_due_to_invalid_export? %>
<p>The ZIP file isn't a Fizzy account export.</p>
<% else %>
<p>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.</p>
<% end %>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
@@ -1,6 +1,11 @@
Unfortunately, we couldn't import your Fizzy account.
This may be due to corrupted export data or a conflict with existing data.
Please try again with a fresh export, or reach out for help if the problem persists.
<% if @import.failed_due_to_conflict? -%>
It looks like the account you are trying to import already exists.
<% elsif @import.failed_due_to_invalid_export? -%>
The ZIP file isn't a Fizzy account export.
<% else -%>
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.
<% end -%>
Need help? Send us an email at support@fizzy.do
@@ -5,10 +5,13 @@
You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
</p>
<% @notifications.group_by(&:card).each do |card, notifications| %>
<h2 class="notification__board"><%= card.board.name %></h2>
<%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %>
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
<% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>
<hr class="notification__board-separator">
<h2 class="notification__board"><%= link_to board.name, board %></h2>
<% board_notifications.group_by(&:card).each do |card, notifications| %>
<%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
<% end %>
<% end %>
<p class="footer">Fizzy emails you about new notifications every few hours. <br>
@@ -1,16 +1,15 @@
Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
<% @notifications.group_by(&:card).each do |card, notifications| %>
--------------------------------------------------------------------------------
<% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>
<%= board.name %>
<%= "-" * board.name.length %>
<%= card.board.name %>
<% board_notifications.group_by(&:card).each do |card, notifications| %>
<%= "##{ card.number } #{ card.title }" %>
--------------------------------------------------------------------------------
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
<% end %>
<% end %>
--------------------------------------------------------------------------------
@@ -2,7 +2,7 @@
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
@@ -1,5 +1,5 @@
json.cache! notification do
json.(notification, :id)
json.(notification, :id, :unread_count)
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
+2 -3
View File
@@ -8,9 +8,8 @@
data-navigable-list-reverse-navigation-value="true"
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
controller: "notifications-tray frame-reloader",
notifications_tray_grouped_class: "notification--grouped",
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
controller: "frame-reloader",
action: "focus@window->frame-reloader#reload" } %>
<div class="tray__item tray__item--hat txt-x-small gap-half">
<div data-navigable-list-target="item" class="full-width">
+1 -1
View File
@@ -1 +1 @@
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
@@ -1,6 +0,0 @@
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
@@ -3,7 +3,7 @@
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
<%= render partial: "notifications/notification", collection: page.records, cached: true %>
</div>
</section>
<% end %>
@@ -11,12 +11,12 @@
<% end %>
<div id="notifications_list" contents>
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
<%= render partial: "notifications/notification", collection: unread, cached: true %>
</div>
<% if unread.any? %>
<% total_unread_count = Current.user.notifications.unread.count %>
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
</div>
@@ -2,18 +2,18 @@
<div class="notifications-help" data-notifications-target="details">
<% case
when platform.firefox? && platform.android? %>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<li>Tap <em><%= icon_tag "lock", alt: "the View site information button" %></em> in the address bar.</li>
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
</ol>
<% when platform.edge? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<h3>Turn on notifications for this website.</h3>
<ol>
<li>Click <em><%= icon_tag "lock", alt: "the View site information button" %></em> left of the address bar.</li>
<li>Under <em>Permissions for this site &gt; Notifications</em>, choose <em>Allow</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
@@ -21,24 +21,24 @@
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.firefox? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<h3>Turn on notifications for this website.</h3>
<ol>
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
<li>Click <em>Settings…</em>.</li>
<li>Click <em>Settings…</em></li>
<li>Click <em>Privacy & Security</em> in the sidebar.</li>
<li>Scroll down to <em>Permissions</em>.</li>
<li>Click <em>Settings</em> next to <em>Notifications</em>.</li>
<li>Select <em>Allow</em> next to <em><%= root_url %></em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
@@ -46,21 +46,21 @@
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.chrome? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<h3>Turn on notifications for this website.</h3>
<ol>
<li>Click the <em><%= icon_tag "sliders", alt: "View site information" %></em> icon in the address bar.</li>
<li>Click <em>Site Settings</em>.</li>
<li>Ensure notifications are <em>Allowed</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<% if platform.windows? %>
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
@@ -68,14 +68,14 @@
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
<% when platform.chrome? && platform.android? %>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<li>Tap the <em><%= icon_tag "menu-dots-vertical", alt: "More options" %></em> menu button.</li>
<li>Tap <em>Settings</em>.</li>
@@ -85,18 +85,18 @@
<li>Tap <em><%= icon_tag "bell-alert", alt: "the notification bell" %></em> and select <em>Allow</em>.</li>
</ol>
<% when platform.safari? && platform.desktop? %>
<h2>Turn on notifications for this website.</h2>
<h3>Turn on notifications for this website.</h3>
<ol>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= request.base_url %></em> in the list.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
</ol>
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
<ol>
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
<li>Click <em>Settings…</em>.</li>
<li>Click <em>Settings…</em></li>
<li>Click the <em>Websites</em> tab.</li>
<li>Click <em>Notifications</em> in the sidebar.</li>
<li>Click <em><%= request.base_url %></em> in the list.</li>
@@ -1,6 +1,6 @@
<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
<div class="notifications-help pwa__instructions hide-in-pwa">
<h2><%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.</h2>
<h3><%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.</h3>
<% case
when platform.edge? %>
<ol>
@@ -20,7 +20,7 @@
<% when platform.safari? && platform.desktop? %>
<ol>
<li>Click <em>File</em> in the top left.</li>
<li>Click <em>Add to Dock…</em>.</li>
<li>Click <em>Add to Dock…</em></li>
</ol>
<% when (platform.safari? || platform.chrome?) && platform.ios? %>
<p>To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.</p>
@@ -1,5 +1,5 @@
<div class="notifications-help hide-in-browser" data-notifications-target="details">
<h2>Check your <%= platform.operating_system %> settings</h2>
<h3>Check your <%= platform.operating_system %> settings</h3>
<% case
when platform.firefox? && platform.android? %>
<ol>
@@ -22,7 +22,7 @@
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for Fizzy.</li>
<% else %>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em>Fizzy</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
@@ -31,7 +31,7 @@
<% when platform.safari? && platform.desktop? %>
<ol>
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>System Settings…</em></li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em>Fizzy</em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
@@ -1,4 +1,3 @@
<%= turbo_frame_tag "notifications" do %>
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
<% end %>
+1 -1
View File
@@ -20,7 +20,7 @@
<% end %>
<% if @board.public_description.present? %>
<div class="card__board-public-description rich-text-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
<div class="card__board-public-description lexxy-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
<%= @board.public_description %>
</div>
<% end %>
@@ -1,8 +1,8 @@
<div class="card__content">
<h1 class="card__title flex align-start gap-half">
<%= tag.span card.title, class: "card__title-link" %>
<%= tag.span card_html_title(card), class: "card__title-link" %>
</h1>
<div class="card__description rich-text-content" data-controller="syntax-highlight">
<div class="card__description lexxy-content" data-controller="syntax-highlight">
<%= card.description %>
</div>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
<li class="popup__item txt-medium">
<%= icon_tag "marker", class: "popup__icon" %>
<%= link_to landing_path(script_name: account.slug), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= account.name %></strong>
<strong class="overflow-ellipsis"><%= account.name %></strong>
<% end %>
</li>
<% end %>
+1 -1
View File
@@ -10,7 +10,7 @@
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
<h2 class="txt-large">Export your data</h2>
<p>This will generate a ZIP archive of all cards you have access to.</p>
<p>Well email you a link to download the file when its ready. The link will expire after 24 hours.</p>
<p>Well email you a link to download the file when its ready. The link will expire after 24 hours.</p>
<div class="flex gap justify-center">
<%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
+7 -4
View File
@@ -15,13 +15,16 @@
<span class="for-screen-reader">Display auto-login QR code</span>
<% end %>
<dialog class="dialog panel shadow" data-dialog-target="dialog">
<dialog class="dialog panel shadow" data-dialog-target="dialog" style="--panel-size: 50ch;">
<p class="margin-none-block-start txt-balance">
<strong>Scan this code to instantly log in on another device:</strong>
</p>
<%= qr_code_image(url) %>
<form method="dialog" class="margin-block-start flex justify-center">
<button class="btn" data-controller="tooltip">
<%= icon_tag "remove" %>
<span class="for-screen-reader">Close dialog</span>
<button class="btn">
<span>Done</span>
</button>
</form>
</dialog>
+54 -1
View File
@@ -5,6 +5,7 @@
# Usage:
# bin/bundle-drift [check] # check for drift (default subcommand)
# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative)
# bin/bundle-drift forward # push Gemfile.lock changes into Gemfile.saas.lock
require "bundler"
require "fileutils"
@@ -118,11 +119,63 @@ class GemfileDriftCorrector
end
end
class GemfileDriftForwarder
def forward
drift = GemfileDriftChecker.new.check
return puts "\nNothing to forward." if drift.empty?
puts "\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\n\n"
original_content = File.read(GEMFILE_SAAS_LOCK)
patched_content = original_content.dup
drift.each do |d|
old_entry = "#{d[:name]} (#{d[:saas]})"
new_entry = "#{d[:name]} (#{d[:oss]})"
puts " #{old_entry} → #{new_entry}"
patched_content.gsub!(old_entry, new_entry)
end
File.write(GEMFILE_SAAS_LOCK, patched_content)
puts "\n▸ Verifying alignment"
new_drift = GemfileDriftChecker.new.check
if new_drift.empty?
puts "\n✓ Lock files are now in sync"
show_diff(original_content, patched_content)
else
puts "\n✗ Lock files still have drift after forwarding."
puts " Restoring original Gemfile.saas.lock."
File.write(GEMFILE_SAAS_LOCK, original_content)
exit 1
end
end
private
def show_diff(original, patched)
require "tempfile"
Tempfile.create("gemfile-saas-lock-original") do |f|
f.write(original)
f.flush
diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null`
unless diff.empty?
puts "\nChanges made to Gemfile.saas.lock:"
puts diff
end
end
end
end
case command = ARGV[0] || "check"
when "check"
exit 1 unless GemfileDriftChecker.new.check.empty?
when "correct"
GemfileDriftCorrector.new.correct
when "forward"
GemfileDriftForwarder.new.forward
else
abort "Usage: bin/bundle-drift [check|correct]"
abort "Usage: bin/bundle-drift [check|correct|forward]"
end
+1 -1
View File
@@ -115,7 +115,7 @@ step "Installing Ruby" mise install --yes
eval "$(mise hook-env -s bash)"
if which pacman >/dev/null 2>&1; then
packages=(imagemagick mariadb-libs openslide libvips gitleaks)
packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks)
if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then
step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}"
fi
+2 -2
View File
@@ -95,7 +95,7 @@
{
"warning_type": "Remote Code Execution",
"warning_code": 24,
"fingerprint": "b00571393e95a8c7d77b1ed963cfc5cc89333bc82f447cf8783549d817990305",
"fingerprint": "f13f9f972c3f026ab4509a66ac284b8b7c1ba6191a3c4c89d2e9fb4584478f6d",
"check_name": "UnsafeReflection",
"message": "Unsafe reflection method `safe_constantize` called on model attribute",
"file": "app/models/notifier.rb",
@@ -139,5 +139,5 @@
"note": "No user input allowed"
}
],
"brakeman_version": "7.1.2"
"brakeman_version": "8.0.1"
}
-1
View File
@@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers"
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge"
pin "marked" # @15.0.11
pin "lexxy"
pin "@rails/activestorage", to: "activestorage.esm.js"
pin "@rails/actiontext", to: "actiontext.esm.js"
+3 -3
View File
@@ -20,7 +20,7 @@ production: &production
schedule: every hour at minute 12
cleanup_webhook_deliveries:
command: "Webhook::Delivery.cleanup"
schedule: every 4 hours at minute 51
schedule: every 15 minutes
cleanup_magic_links:
command: "MagicLink.cleanup"
schedule: every 4 hours
@@ -41,11 +41,11 @@ production: &production
schedule: every 60 seconds
<% end %>
beta:
beta: &beta
# Only Solid Queue maintenance
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
staging: *production
staging: *beta
development: *production
@@ -0,0 +1,6 @@
class AddCardIdToNotifications < ActiveRecord::Migration[8.2]
def change
add_column :notifications, :card_id, :uuid
add_column :notifications, :unread_count, :integer, null: false, default: 0
end
end
@@ -0,0 +1,84 @@
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
BATCH_SIZE = 10_000
class Notification < ActiveRecord::Base
self.table_name = "notifications"
end
def change
reversible do |dir|
dir.up do
populate_card_id
collapse_duplicates
end
end
change_column_null :notifications, :card_id, false
add_index :notifications, [ :user_id, :card_id ], unique: true
end
private
def populate_card_id
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE events.eventable_type
WHEN 'Card' THEN events.eventable_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
END
FROM events
WHERE events.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Event'
SQL
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE mentions.source_type
WHEN 'Card' THEN mentions.source_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
END
FROM mentions
WHERE mentions.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Mention'
SQL
end
def collapse_duplicates
loop do
duplicates = Notification.find_by_sql(<<~SQL)
SELECT user_id, card_id,
MAX(id) AS keep_id,
COUNT(*) AS total,
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
FROM notifications
WHERE card_id IS NOT NULL
GROUP BY user_id, card_id
HAVING COUNT(*) > 1
LIMIT #{BATCH_SIZE}
SQL
break if duplicates.empty?
duplicates.each do |row|
Notification.where(user_id: row.user_id, card_id: row.card_id)
.where.not(id: row.keep_id)
.delete_all
Notification.where(id: row.keep_id)
.update_all(unread_count: row.unread_total.to_i)
end
end
# Set unread_count for remaining non-collapsed notifications
execute(<<~SQL)
UPDATE notifications
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
WHERE unread_count = 0 AND card_id IS NOT NULL
SQL
end
end
@@ -0,0 +1,5 @@
class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2]
def change
add_column :account_imports, :failure_reason, :string
end
end
@@ -0,0 +1,8 @@
class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2]
def change
add_index :notifications, [ :user_id, :read_at, :updated_at ],
order: { read_at: :desc, updated_at: :desc },
name: "index_notifications_on_user_id_and_read_at_and_updated_at"
remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at"
end
end

Some files were not shown because too many files have changed in this diff Show More