diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 000000000..dba71e970
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1 @@
+@../AGENTS.md
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index bbd1eb22a..d89381607 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -34,6 +34,9 @@ jobs:
- runner: ubuntu-latest
platform: linux/amd64
arch: amd64
+ - runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ arch: arm64
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -170,11 +173,13 @@ jobs:
docker buildx imagetools create \
--tag "$tag" \
--annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \
- "${src_tag}-amd64"
+ "${src_tag}-amd64" \
+ "${src_tag}-arm64"
else
docker buildx imagetools create \
--tag "$tag" \
- "${src_tag}-amd64"
+ "${src_tag}-amd64" \
+ "${src_tag}-arm64"
fi
done <<< "$tags"
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 43c994c2d..000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-@AGENTS.md
diff --git a/Gemfile.lock b/Gemfile.lock
index 0e48985b0..ea92feb39 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,8 +1,8 @@
GIT
remote: https://github.com/basecamp/lexxy
- revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
+ revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
specs:
- lexxy (0.1.24.beta)
+ lexxy (0.7.0.beta)
rails (>= 8.0.2)
GIT
@@ -147,7 +147,7 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.20.1)
+ bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (7.1.2)
racc
@@ -449,7 +449,7 @@ GEM
timeout (0.6.0)
trilogy (2.9.0)
tsort (0.2.0)
- turbo-rails (2.0.20)
+ turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index f340b4ae4..6dcb7b9ee 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,9 +21,9 @@ GIT
GIT
remote: https://github.com/basecamp/lexxy
- revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
+ revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
specs:
- lexxy (0.1.24.beta)
+ lexxy (0.7.0.beta)
rails (>= 8.0.2)
GIT
@@ -225,7 +225,7 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.20.1)
+ bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (7.1.2)
racc
@@ -568,7 +568,7 @@ GEM
timeout (0.6.0)
trilogy (2.9.0)
tsort (0.2.0)
- turbo-rails (2.0.20)
+ turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
diff --git a/app/assets/images/person.svg b/app/assets/images/person.svg
index 1e29ed27d..b126d685d 100644
--- a/app/assets/images/person.svg
+++ b/app/assets/images/person.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 2c0c67ef9..1083c2ffc 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -378,6 +378,7 @@
}
.cards__menu {
+ position: relative;
z-index: var(--z-popup);
}
@@ -535,16 +536,33 @@
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
- .card__comments {
- --column-gap: 0.8ch;
+ .card__counts {
+ --gap: 0.5ch;
+ align-items: flex-end;
display: flex;
- margin-block-end: calc(var(--block-space) * -1.7);
- margin-inline-end: calc(var(--card-padding-inline) * -0.5);
+ flex-shrink: 0;
+ gap: calc(2 * var(--gap));
+ margin-inline: auto calc(var(--card-padding-inline) * -0.5);
+ padding-inline-start: var(--gap);
+ }
- .icon {
- --icon-size: 1.6em;
+ .card__boosts,
+ .card__comments {
+ --icon-size: 1.6em;
+ align-items: center;
+ display: flex;
+ flex-shrink: 0;
+ font-weight: 600;
+ gap: var(--gap);
+
+ img {
+ block-size: var(--icon-size);
+ inline-size: var(--icon-size);
+ }
+
+ .icon--comment {
color: var(--card-color);
}
}
@@ -583,19 +601,36 @@
.local-time-value {
font-weight: inherit;
}
+
+ @media (max-width: 639px) {
+ inline-size: auto;
+ }
}
- .card:has(.card__background img:not([src=""])) {
+ &:has(.card__background img:not([src=""])) {
.card__content,
.card__meta,
+ .card__boosts,
+ .card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 0;
transition: opacity 0.2s ease-in-out;
+ }
- @media (any-hover: hover) {
- .card:hover & {
+ @media (any-hover: hover) {
+ &:hover {
+ .card__content,
+ .card__footer,
+ .card__boosts,
+ .card__comments,
+ .card__column-name:not(.card__column-name--current) {
opacity: 1;
}
+
+ .card__background img {
+ filter: blur(3px) brightness(1.2);
+ opacity: 0.2;
+ }
}
}
}
@@ -604,13 +639,6 @@
inset-inline-start: 100%;
translate: -90% -40%;
}
-
- @media (any-hover: hover) {
- .card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) {
- filter: blur(3px) brightness(1.2);
- opacity: 0.2;
- }
- }
}
/* Considering
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 8d1340c23..b3d37d68a 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -25,7 +25,7 @@
position: relative;
&:has(dialog[open]) {
- z-index: 2;
+ z-index: 3;
}
&:has(.card-perma__star-input:checked) {
@@ -34,6 +34,23 @@
}
}
+ @media (max-width: 799px) {
+ --half-btn-height: 1.25rem;
+ --padding-inline: 1.5ch;
+
+ column-gap: 0;
+ grid-template-areas:
+ "notch-top notch-top notch-top"
+ "card card card"
+ "actions-left notch-bottom actions-right"
+ "closure-message closure-message closure-message";
+ grid-template-columns: 1fr auto 1fr;
+ inline-size: calc(100% + 2 * var(--padding-inline));
+ margin-inline: calc(-1 * var(--padding-inline));
+ max-inline-size: none;
+ position: relative;
+ }
+
.card {
--card-aspect-ratio: 2 / 0.95;
--lexxy-bg-color: var(--card-bg-color);
@@ -47,6 +64,8 @@
}
.card__body {
+ position: relative;
+
@media (max-width: 639px) {
flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5);
@@ -86,6 +105,11 @@
}
}
+ .card__meta {
+ grid-area: meta;
+ margin-inline-end: auto;
+ }
+
.card__stages {
max-inline-size: 32ch;
@@ -119,15 +143,69 @@
}
}
- .board-picker__button {
- --btn-border-size: 0;
+ .card__footer {
+ --btn-size: 2.5rem;
- font-size: 0.5em;
- margin-inline-start: 1ch;
+ display: flex;
+ gap: 0.5ch;
+ inline-size: 100%;
+ text-align: start;
- + .panel {
- --panel-size: 18ch;
+ /* Switch to grid layout so that the bg zoom button can stay next to the
+ * meta element, and the reactions can sit below */
+ &:has(.reaction) {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-areas:
+ "meta bg-zoom"
+ "reactions reactions";
}
+
+ @media (max-width: 639px) {
+ font-size: var(--text-x-small);
+ }
+ }
+
+ .reactions {
+ --reaction-size: var(--btn-size);
+
+ align-self: flex-end;
+ display: flex;
+ gap: 0.5ch;
+ grid-area: reactions;
+ margin-inline-start: auto;
+
+ &:has(.reaction) {
+ --padding: calc(var(--card-padding-block) / 2);
+ --reaction-size: 1.6875rem;
+
+ margin-block: var(--padding) calc(-1 * var(--padding));
+ padding-block-start: var(--padding);
+ position: relative;
+
+ &:before {
+ border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%);
+ content: "";
+ inset: 0 calc(-1 * var(--card-padding-inline)) auto;
+ position: absolute;
+ }
+ }
+
+ &:not(:has(.reaction)) {
+ position: static;
+
+ .reactions__trigger {
+ --btn-border-color: var(--color-ink-light);
+ }
+ }
+ }
+
+ .reaction__popup.popup {
+ inline-size: max-content;
+ }
+
+ .card__zoom-bg-btn {
+ grid-area: bg-zoom;
}
.bubble {
@@ -143,23 +221,6 @@
inset: calc(var(--bubble-size) / 1.5) 0 auto auto;
}
}
-
- @media (max-width: 799px) {
- --half-btn-height: 1.5rem;
- --padding-inline: 1.5ch;
-
- column-gap: 0;
- grid-template-areas:
- "notch-top notch-top notch-top"
- "card card card"
- "actions-left notch-bottom actions-right"
- "closure-message closure-message closure-message";
- grid-template-columns: 1fr auto 1fr;
- inline-size: calc(100% + 2 * var(--padding-inline));
- margin-inline: calc(-1 * var(--padding-inline));
- max-inline-size: none;
- position: relative;
- }
}
/* Child items
@@ -292,10 +353,7 @@
/* Overlap the card BG by half the button height */
&:has(.btn) {
- &,
- ~ .card-perma__closure-message {
- translate: 0 calc(-1 * var(--half-btn-height));
- }
+ translate: 0 calc(-1 * var(--half-btn-height));
}
form {
@@ -308,12 +366,6 @@
--btn-color: var(--color-ink-inverted);
}
- .btn--plain {
- --btn-color: var(--card-color);
-
- text-decoration: underline;
- }
-
.btn--reversed {
--btn-background: var(--color-canvas);
--btn-color: var(--card-color);
@@ -341,11 +393,25 @@
.card-perma__closure-message {
color: var(--card-color);
grid-area: closure-message;
- margin-block-start: 0.5ch;
+ margin-block: var(--block-space) var(--block-space-double);
padding-inline: 1ch;
- form {
- display: inline;
+ .btn--plain {
+ --btn-color: var(--card-color);
+
+ text-decoration: underline;
+ }
+
+ @media (max-width: 799px) {
+ margin-block: var(--block-space-half);
+ translate: 0 calc(-0.5 * var(--half-btn-height));
+ }
+
+ @media (min-width: 800px) {
+ .card-perma__notch--bottom:has(.btn) ~ & {
+ margin-block: var(--block-space-half) var(--block-space);
+ translate: 0 calc(-0.5 * var(--half-btn-height));
+ }
}
}
}
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index 3fb0a7f4d..cbaa66c91 100644
--- a/app/assets/stylesheets/cards.css
+++ b/app/assets/stylesheets/cards.css
@@ -62,6 +62,8 @@
}
.card__board {
+ align-items: center;
+ align-self: start;
background-color: var(--card-color);
border-radius: var(--border-radius) 0 var(--border-radius) 0;
color: var(--color-ink-inverted);
@@ -71,21 +73,19 @@
min-inline-size: 0;
padding-block: 0.25lh;
padding-inline: var(--card-padding-inline) 1ch;
+ position: relative;
+ transition: background-color 100ms ease-out;
- &:has(.board-picker__button) {
- cursor: pointer;
- transition: background-color 100ms ease-out;
-
+ &:has(.btn) {
@media (any-hover: hover) {
&:hover {
background-color: color-mix(in srgb, var(--card-color) 90%, var(--color-ink));
}
}
+ }
- &:has(.btn:focus-visible) {
- outline: var(--focus-ring-size) solid var(--focus-ring-color);
- outline-offset: var(--focus-ring-offset);
- }
+ dialog {
+ inset-block-start: 100%;
}
}
@@ -99,9 +99,11 @@
}
.card__board-name {
+ align-items: center;
border-inline-start: 1px solid color-mix(in hsl, transparent 75%, currentColor);
color: currentColor;
display: flex;
+ gap: 0.25ch;
margin-inline-start: var(--card-header-space);
max-inline-size: 100%;
min-inline-size: 0;
@@ -109,14 +111,59 @@
text-transform: uppercase;
}
+ .card__board-picker-button {
+ inset: 0;
+ position: absolute;
+ }
+
.card__tags {
--btn-color: var(--card-color);
align-items: center;
+ align-self: stretch;
color: var(--card-text-color);
display: flex;
- gap: var(--card-header-space);
+ gap: 0.5ch;
min-inline-size: 0;
+
+ [data-controller="dialog"] {
+ align-items: center;
+ align-self: stretch;
+ display: flex;
+ position: relative;
+ }
+
+ .popup {
+ --panel-size: 18ch;
+
+ inset-block-start: 100%;
+ }
+ }
+
+ .card__tag-picker {
+ --panel-border-radius: 2em;
+ --panel-padding: 0.5em 0.7em;
+ --panel-size: max-content;
+
+ inline-size: auto !important;
+ inset: 0 auto auto 0;
+ max-inline-size: var(--panel-size) !important;
+ position: absolute;
+ z-index: 2;
+
+ &[open] {
+ display: flex;
+ }
+
+ .input {
+ --input-padding: 0.2em 0.5em;
+
+ inline-size: 18ch;
+ }
+ }
+
+ .card__tag-picker-button {
+ font-size: 0.6em;
}
.card__tag {
@@ -207,9 +254,10 @@
padding-block: var(--block-space-half);
}
- /* Meta
+ /* Footer
/* ------------------------------------------------------------------------ */
+ /* Card metadata */
.card__meta {
--meta-spacer-block: 0.5ch;
--meta-spacer-inline: 0.75ch;
@@ -331,11 +379,12 @@
display: flex;
flex-direction: column;
font-weight: bold;
- inset: auto 1ch 1ch auto;
+ inset: auto 0 -1lh auto;
justify-content: center;
max-inline-size: 25ch;
min-inline-size: 16ch;
padding: 1ch;
+ pointer-events: none;
position: absolute;
rotate: 5deg;
transform-origin: top right;
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index 006cbb985..a8605503b 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -80,6 +80,11 @@
display: none !important;
}
}
+
+ .reactions {
+ margin-block-start: var(--block-space-half);
+ margin-inline: calc(var(--column-gap) / -1);
+ }
}
.comment__author {
diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css
index 5d6a7eb0e..1336cad9b 100644
--- a/app/assets/stylesheets/dialog.css
+++ b/app/assets/stylesheets/dialog.css
@@ -1,4 +1,9 @@
@layer components {
+ /* Prevent page scrolling when modal dialog is open */
+ html:has(dialog:modal) {
+ overflow: hidden;
+ }
+
:is(.dialog) {
border: 0;
opacity: 0;
@@ -35,4 +40,9 @@
}
}
}
+
+ /* Ensure padding from viewport edges */
+ .dialog.panel {
+ max-inline-size: calc(100vw - var(--inline-space-double) * 2);
+ }
}
diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css
index 73e446185..5601b1988 100644
--- a/app/assets/stylesheets/events.css
+++ b/app/assets/stylesheets/events.css
@@ -287,6 +287,7 @@
.event {
--column-gap: 0.7ch;
+ --event-padding: 0.6em;
background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
border-radius: 0.2em;
@@ -296,7 +297,7 @@
max-inline-size: 100%;
min-inline-size: 0;
overflow: clip;
- padding: 0.6em;
+ padding: var(--event-padding);
position: relative;
z-index: 0;
@@ -321,12 +322,15 @@
z-index: 0;
}
+ .card__header {
+ inline-size: 100%;
+ margin-block-start: calc(-1 * var(--event-padding));
+ }
+
.card__board {
background-color: transparent;
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
font-size: 0.7em;
- padding: 0;
- translate: 0 -0.3em;
}
.card__board-name {
@@ -373,16 +377,11 @@
}
.event__icon {
+ color: var(--card-color);
display: grid;
- margin: -0.3em -0.3em auto auto;
+ margin-inline-start: auto;
place-content: center;
-
- .icon {
- background-color: var(--card-color);
- block-size: 1em;
- inline-size: 1em;
- grid-area: 1 / 1;
- }
+ translate: calc(var(--event-padding) / 2);
}
.event__timestamp {
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index 21ee18301..c35f14fee 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -7,7 +7,8 @@
position: relative;
overflow: visible;
- figure.node--selected {
+ figure.node--selected,
+ div.node--selected {
&:has(img) {
img {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
@@ -22,6 +23,31 @@
}
}
+ .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. */
@@ -101,6 +127,11 @@
/* Toolbar
/* ------------------------------------------------------------------------ */
+ /* TODO: Temporary - hide table button while we work on the styles */
+ button[name="table"] {
+ display: none;
+ }
+
lexxy-toolbar {
--lexxy-toolbar-icon-size: 1em;
@@ -115,7 +146,7 @@
position: relative;
position: sticky;
inset-block-start: 0;
- z-index: 1;
+ z-index: 2;
}
.lexxy-editor__toolbar-button {
@@ -166,7 +197,7 @@
inset-inline-end: 0;
padding: 4px;
position: absolute;
- z-index: 1;
+ z-index: 2;
}
.lexxy-editor__toolbar-spacer {
@@ -188,7 +219,7 @@
font-size: var(--text-small);
padding: var(--lexxy-dropdown-padding);
position: absolute;
- z-index: 1;
+ z-index: 2;
button {
block-size: var(--lexxy-dropdown-btn-size);
@@ -288,7 +319,130 @@
}
}
- /* Prompt ,enu (@mentions, etc.)
+ .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;
+
+ &:hover {
+ background-color: var(--color-ink-darker);
+ }
+
+ &: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;
+ }
+ }
+ }
+
+ .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;
+ }
+ }
+ }
+ }
+ }
+
+ /* Prompt menu (@mentions, etc.)
/* ------------------------------------------------------------------------ */
.lexxy-prompt-menu {
diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css
index 96df0e00b..8226a5261 100644
--- a/app/assets/stylesheets/native.css
+++ b/app/assets/stylesheets/native.css
@@ -6,6 +6,8 @@
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
+ --footer-height: 0;
+
-webkit-tap-highlight-color: transparent;
.hide-on-native {
@@ -28,11 +30,27 @@
}
}
+ /* Card columns
+ /* ------------------------------------------------------------------------ */
+
+ .board-tools.card {
+ padding-block-start: 0;
+ }
+
/* Card perma
/* ------------------------------------------------------------------------ */
.card-perma {
margin-block-start: 0;
+
+ &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg {
+ padding-block: clamp(0.25rem, 2vw, var(--padding-block));
+ }
+ }
+
+ .card-perma__closure-message {
+ margin-block: var(--block-space);
+ translate: unset;
}
/* Search
@@ -43,3 +61,25 @@
}
}
}
+
+[data-bridge-components~=form] {
+ [data-controller~=bridge--form] {
+ [data-bridge--form-target~=submit] {
+ display: none;
+ }
+ }
+}
+
+[data-bridge-components~=overflow-menu] {
+ [data-controller~=bridge--overflow-menu] {
+ [data-bridge--overflow-menu-target~=item] {
+ display: none;
+ }
+ }
+}
+
+[data-bridge-components~=buttons] {
+ [data-bridge--buttons-target~=button] {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css
index 02ae8969e..aa0b8c1da 100644
--- a/app/assets/stylesheets/popup.css
+++ b/app/assets/stylesheets/popup.css
@@ -21,11 +21,16 @@
display: var(--popup-display);
}
+ /* The .pop-up--align- classes are used for initial alignment.
+ * The orient JS helper layers on the .orient- to avoid running
+ * off the edge of the screen and will override .popup--align */
+ &:where(.popup--align-left),
&.orient-left {
inset-inline: auto 0;
transform: translateX(0);
}
+ &:where(.popup--align-right),
&.orient-right {
inset-inline: 0 auto;
transform: translateX(0);
@@ -48,6 +53,10 @@
.popup__title {
font-weight: 800;
white-space: nowrap;
+
+ &[tabindex="-1"]:focus-visible {
+ outline: unset;
+ }
}
/* Hide lists when all the items within are hidden */
@@ -123,6 +132,10 @@
}
}
+ &:has(.popup__btn[disabled]) {
+ pointer-events: none;
+ }
+
.checked {
display: none;
}
@@ -155,7 +168,7 @@
max-inline-size: 100%;
padding: var(--inline-space-half) var(--popup-item-padding-inline);
text-align: start;
-
+
&:focus-visible {
z-index: 1;
}
diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css
index 5ed9c3fdf..cd55b5196 100644
--- a/app/assets/stylesheets/print.css
+++ b/app/assets/stylesheets/print.css
@@ -164,8 +164,8 @@
.comments__subscribers,
.card__meta-avatars--assignees > div > div:last-child,
.steps > .step:last-child,
- div:has(> .tag-picker__button),
- div:has(> .board-picker__button),
+ .card__board-name .icon,
+ div:has(> .card__tag-picker-button),
.delete-card,
.header--card .header__title {
display: none;
diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css
index d1cd0a03f..64f2a5164 100644
--- a/app/assets/stylesheets/reactions.css
+++ b/app/assets/stylesheets/reactions.css
@@ -10,8 +10,6 @@
flex-wrap: wrap;
gap: var(--inline-space-half);
inline-size: 100%;
- margin-block-start: var(--block-space-half);
- margin-inline: calc(var(--column-gap) / -1);
&:has([open]) {
z-index: var(--z-popup);
@@ -34,11 +32,8 @@
.reactions__trigger {
--btn-border-color: var(--color-ink-lightest);
- background-color: var(--color-ink-lightest);
- &:not(:hover) {
- opacity: 0.66;
- }
+ background-color: var(--color-ink-lightest);
}
}
}
diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css
index 88e4d2ae2..bf5328478 100644
--- a/app/assets/stylesheets/rich-text-content.css
+++ b/app/assets/stylesheets/rich-text-content.css
@@ -209,6 +209,45 @@
}
}
}
+
+ /* 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
diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css
deleted file mode 100644
index 90d51e011..000000000
--- a/app/assets/stylesheets/tags.css
+++ /dev/null
@@ -1,33 +0,0 @@
-@layer components {
- .tag-picker {
- --panel-border-radius: 2em;
- --panel-padding: 0.5em 0.7em;
- --panel-size: max-content;
-
- inline-size: auto !important;
- inset: 0 auto auto 0;
- max-inline-size: var(--panel-size) !important;
- position: absolute;
- z-index: 2;
-
- &[open] {
- display: flex;
- }
-
- .input {
- --input-padding: 0.2em 0.5em;
-
- inline-size: 18ch;
- }
- }
-
- .tag-picker__button {
- font-size: 0.6em;
- margin-block-start: 0.1em;
- margin-inline-start: 0.2em;
-
- + .panel {
- --panel-size: 18ch;
- }
- }
-}
diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css
index 7793b6f2d..0e47857ab 100644
--- a/app/assets/stylesheets/trays.css
+++ b/app/assets/stylesheets/trays.css
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
+ .card__boosts,
.card__comments,
.card__closed {
display: none;
diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb
index 0f1277a3c..0c822360e 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
include CardScoped
before_action :set_comment
- before_action :set_reaction, only: %i[ destroy ]
- before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
+ before_action :set_reactable
+
+ with_options only: :destroy do
+ before_action :set_reaction
+ before_action :ensure_permission_to_administer_reaction
+ end
def index
+ render "reactions/index"
end
def new
+ render "reactions/new"
end
def create
- @reaction = @comment.reactions.create!(params.expect(reaction: :content))
+ @reaction = @reactable.reactions.create!(params.expect(reaction: :content))
respond_to do |format|
- format.turbo_stream
+ format.turbo_stream { render "reactions/create" }
format.json { head :created }
end
end
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
@reaction.destroy
respond_to do |format|
- format.turbo_stream
+ format.turbo_stream { render "reactions/destroy" }
format.json { head :no_content }
end
end
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
@comment = @card.comments.find(params[:comment_id])
end
- def set_reaction
- @reaction = @comment.reactions.find(params[:id])
+ def set_reactable
+ @reactable = @comment
end
- def ensure_permision_to_administer_reaction
+ def set_reaction
+ @reaction = @reactable.reactions.find(params[:id])
+ end
+
+ def ensure_permission_to_administer_reaction
head :forbidden if Current.user != @reaction.reacter
end
end
diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index 27c507abe..f2b318813 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController
before_action :set_comment, only: %i[ show edit update destroy ]
before_action :ensure_creatorship, only: %i[ edit update destroy ]
+ before_action :ensure_card_is_commentable, only: :create
def index
set_page_and_extract_portion_from @card.comments.chronologically
@@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController
head :forbidden if Current.user != @comment.creator
end
+ def ensure_card_is_commentable
+ head :forbidden unless @card.commentable?
+ end
+
def comment_params
params.expect(comment: [ :body, :created_at ])
end
diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb
new file mode 100644
index 000000000..4105a4121
--- /dev/null
+++ b/app/controllers/cards/reactions_controller.rb
@@ -0,0 +1,49 @@
+class Cards::ReactionsController < ApplicationController
+ include CardScoped
+
+ before_action :set_reactable
+
+ with_options only: :destroy do
+ before_action :set_reaction
+ before_action :ensure_permission_to_administer_reaction
+ end
+
+ def index
+ render "reactions/index"
+ end
+
+ def new
+ render "reactions/new"
+ end
+
+ def create
+ @reaction = @reactable.reactions.create!(params.expect(reaction: :content))
+
+ respond_to do |format|
+ format.turbo_stream { render "reactions/create" }
+ format.json { head :created }
+ end
+ end
+
+ def destroy
+ @reaction.destroy
+
+ respond_to do |format|
+ format.turbo_stream { render "reactions/destroy" }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ def set_reactable
+ @reactable = @card
+ end
+
+ def set_reaction
+ @reaction = @reactable.reactions.find(params[:id])
+ end
+
+ def ensure_permission_to_administer_reaction
+ head :forbidden if Current.user != @reaction.reacter
+ end
+end
diff --git a/app/controllers/cards/self_assignments_controller.rb b/app/controllers/cards/self_assignments_controller.rb
new file mode 100644
index 000000000..a38c99cf0
--- /dev/null
+++ b/app/controllers/cards/self_assignments_controller.rb
@@ -0,0 +1,17 @@
+class Cards::SelfAssignmentsController < ApplicationController
+ include CardScoped
+
+ def create
+ if @card.toggle_assignment(Current.user)
+ respond_to do |format|
+ format.turbo_stream { render "cards/assignments/create" }
+ format.json { head :no_content }
+ end
+ else
+ respond_to do |format|
+ format.turbo_stream { render "cards/assignments/create" }
+ format.json { head :unprocessable_entity }
+ end
+ end
+ end
+end
diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb
index cbe3e9e32..82166043e 100644
--- a/app/controllers/webhooks/activations_controller.rb
+++ b/app/controllers/webhooks/activations_controller.rb
@@ -1,8 +1,10 @@
class Webhooks::ActivationsController < ApplicationController
+ include BoardScoped
+
before_action :ensure_admin
def create
- webhook = Current.account.webhooks.find(params[:webhook_id])
+ webhook = @board.webhooks.find(params[:webhook_id])
webhook.activate
redirect_to webhook
diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb
index 1ace03574..c5a4ae1c6 100644
--- a/app/helpers/accesses_helper.rb
+++ b/app/helpers/accesses_helper.rb
@@ -48,7 +48,8 @@ module AccessesHelper
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },
aria: { labelledby: dom_id(board, :involvement_label) },
title: (label_text if icon_only),
- class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do
+ class: class_names("btn", { "btn--reversed": access.watching? && icon_only }),
+ data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") +
tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index a1960b341..9b9ee89b3 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -4,7 +4,8 @@ module BoardsHelper
end
def link_to_edit_board(board)
- link_to edit_board_path(board), class: "btn", data: { controller: "tooltip" } do
+ link_to edit_board_path(board), class: "btn",
+ data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
end
end
diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb
new file mode 100644
index 000000000..5fb4508b6
--- /dev/null
+++ b/app/helpers/bridge_helper.rb
@@ -0,0 +1,5 @@
+module BridgeHelper
+ def bridge_icon(name)
+ asset_url("#{name}.svg")
+ end
+end
diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb
index f1a50bbe9..4329dab88 100644
--- a/app/helpers/entropy_helper.rb
+++ b/app/helpers/entropy_helper.rb
@@ -16,6 +16,7 @@ module EntropyHelper
{
stalledAfterDays: card.entropy.days_before_reminder,
lastActivitySpikeAt: card.last_activity_spike_at.iso8601,
+ updatedAt: card.updated_at.iso8601,
action: "Stalled"
}
end
diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb
index 6c3f01725..a0529b11a 100644
--- a/app/helpers/filters_helper.rb
+++ b/app/helpers/filters_helper.rb
@@ -40,6 +40,10 @@ module FiltersHelper
}, &block
end
+ def filter_title(title)
+ tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" }
+ end
+
def collapsible_nav_section(title, **properties, &block)
tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do
concat(tag.summary(class: "popup__section-title") do
diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb
index 095655e3b..2902f809d 100644
--- a/app/helpers/forms_helper.rb
+++ b/app/helpers/forms_helper.rb
@@ -9,4 +9,23 @@ module FormsHelper
form_with(**attributes, data: data) { }
end
end
+
+ def bridged_form_with(**attributes, &)
+ data = attributes.delete(:data) || {}
+ controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip
+ actions = [
+ data[:action],
+ "turbo:submit-start->bridge--form#submitStart",
+ "turbo:submit-end->bridge--form#submitEnd"
+ ].compact.join(" ").strip
+
+ data[:controller] = controllers
+ data[:action] = actions
+
+ if block_given?
+ form_with **attributes, data: data, &
+ else
+ form_with(**attributes, data: data) { }
+ end
+ end
end
diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb
new file mode 100644
index 000000000..5c47781f6
--- /dev/null
+++ b/app/helpers/reactions_helper.rb
@@ -0,0 +1,10 @@
+module ReactionsHelper
+ def reaction_path_prefix_for(reactable)
+ case reactable
+ when Card then [ reactable ]
+ when Comment then [ reactable.card, reactable ]
+ else
+ raise ArgumentError, "Unknown reactable type: #{reactable.class}"
+ end
+ end
+end
diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb
index f3bb077c8..a2e14af29 100644
--- a/app/helpers/webhooks_helper.rb
+++ b/app/helpers/webhooks_helper.rb
@@ -25,7 +25,7 @@ module WebhooksHelper
def link_to_webhooks(board, &)
link_to board_webhooks_path(board_id: board),
class: [ "btn", { "btn--reversed": board.webhooks.any? } ],
- data: { controller: "tooltip" } do
+ data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do
icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader")
end
end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index bdafb95b0..c43a45eaa 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,8 +1,8 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
+import "@hotwired/hotwire-native-bridge"
import "initializers"
import "controllers"
import "lexxy"
import "@rails/actiontext"
-
diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js
new file mode 100644
index 000000000..47d09a158
--- /dev/null
+++ b/app/javascript/controllers/bridge/buttons_controller.js
@@ -0,0 +1,43 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+import { BridgeElement } from "@hotwired/hotwire-native-bridge"
+
+export default class extends BridgeComponent {
+ static component = "buttons"
+ static targets = [ "button" ]
+
+ buttonTargetConnected() {
+ this.notifyBridgeOfConnect()
+ }
+
+ buttonTargetDisconnected() {
+ if (!this.#isControllerTearingDown()) {
+ this.notifyBridgeOfConnect()
+ }
+ }
+
+ notifyBridgeOfConnect() {
+ const buttons = this.#enabledButtonTargets
+ .map((target, index) => {
+ const element = new BridgeElement(target)
+ return { ...element.getButton(), index }
+ })
+
+ this.send("connect", { buttons }, message => {
+ this.#clickButton(message)
+ })
+ }
+
+ #clickButton(message) {
+ const selectedIndex = message.data.selectedIndex
+ this.#enabledButtonTargets[selectedIndex].click()
+ }
+
+ get #enabledButtonTargets() {
+ return this.buttonTargets
+ .filter(target => !target.closest("[data-bridge-disabled]"))
+ }
+
+ #isControllerTearingDown() {
+ return !document.body.contains(this.element)
+ }
+}
diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js
new file mode 100644
index 000000000..a3c780e62
--- /dev/null
+++ b/app/javascript/controllers/bridge/form_controller.js
@@ -0,0 +1,62 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+import { BridgeElement } from "@hotwired/hotwire-native-bridge"
+
+export default class extends BridgeComponent {
+ static component = "form"
+ static targets = [ "submit", "cancel" ]
+ static values = { submitTitle: String }
+
+ submitTargetConnected() {
+ this.notifyBridgeOfConnect()
+ this.#observeSubmitTarget()
+ }
+
+ submitTargetDisconnected() {
+ this.notifyBridgeOfDisonnect()
+ this.submitObserver?.disconnect()
+ }
+
+ notifyBridgeOfConnect() {
+ const submitElement = new BridgeElement(this.submitTarget)
+ const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null
+
+ const submitButton = { title: submitElement.title }
+ const cancelButton = cancelElement ? { title: cancelElement.title } : null
+
+ this.send("connect", { submitButton, cancelButton }, message => this.receive(message))
+ }
+
+ receive(message) {
+ switch (message.event) {
+ case "submit":
+ this.submitTarget.click()
+ break
+ case "cancel":
+ this.cancelTarget.click()
+ break
+ }
+ }
+
+ notifyBridgeOfDisonnect() {
+ this.send("disconnect")
+ }
+
+ submitStart() {
+ this.send("submitStart")
+ }
+
+ submitEnd() {
+ this.send("submitEnd")
+ }
+
+ #observeSubmitTarget() {
+ this.submitObserver = new MutationObserver(() => {
+ this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled")
+ })
+
+ this.submitObserver.observe(this.submitTarget, {
+ attributes: true,
+ attributeFilter: [ "disabled" ]
+ })
+ }
+}
diff --git a/app/javascript/controllers/bridge/insets_controller.js b/app/javascript/controllers/bridge/insets_controller.js
new file mode 100644
index 000000000..1cef15690
--- /dev/null
+++ b/app/javascript/controllers/bridge/insets_controller.js
@@ -0,0 +1,31 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+
+// Bridge component to control custom safe-area insets from native apps.
+// Sets CSS variables --injected-safe-inset-(top|right|bottom|left).
+export default class extends BridgeComponent {
+ static component = "insets"
+
+ connect() {
+ super.connect()
+ this.notifyBridgeOfConnect()
+ }
+
+ disconnect() {
+ super.disconnect()
+ this.send("disconnect")
+ }
+
+ notifyBridgeOfConnect() {
+ this.send("connect", {}, message => {
+ this.#setInsets(message.data)
+ })
+ }
+
+ #setInsets({ top, right, bottom, left }) {
+ const root = document.documentElement.style
+ root.setProperty("--injected-safe-inset-top", `${top}px`)
+ root.setProperty("--injected-safe-inset-right", `${right}px`)
+ root.setProperty("--injected-safe-inset-bottom", `${bottom}px`)
+ root.setProperty("--injected-safe-inset-left", `${left}px`)
+ }
+}
diff --git a/app/javascript/controllers/bridge/overflow_menu_controller.js b/app/javascript/controllers/bridge/overflow_menu_controller.js
new file mode 100644
index 000000000..ba2045dc6
--- /dev/null
+++ b/app/javascript/controllers/bridge/overflow_menu_controller.js
@@ -0,0 +1,43 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+import { BridgeElement } from "@hotwired/hotwire-native-bridge"
+
+export default class extends BridgeComponent {
+ static component = "overflow-menu"
+ static targets = [ "item" ]
+
+ itemTargetConnected() {
+ this.notifyBridgeOfConnect()
+ }
+
+ itemTargetDisconnected() {
+ if (!this.#isControllerTearingDown) {
+ this.notifyBridgeOfConnect()
+ }
+ }
+
+ notifyBridgeOfConnect() {
+ const items = this.#enabledItemTargets
+ .map((target, index) => {
+ const element = new BridgeElement(target)
+ return { title: element.title, index }
+ })
+
+ this.send("connect", { items }, message => {
+ this.#clickItem(message)
+ })
+ }
+
+ #clickItem(message) {
+ const selectedIndex = message.data.selectedIndex
+ this.#enabledItemTargets[selectedIndex].click()
+ }
+
+ get #enabledItemTargets() {
+ return this.itemTargets
+ .filter(target => !target.closest("[data-bridge-disabled]"))
+ }
+
+ #isControllerTearingDown() {
+ return !document.body.contains(this.element)
+ }
+}
diff --git a/app/javascript/controllers/bridge/text_size_controller.js b/app/javascript/controllers/bridge/text_size_controller.js
new file mode 100644
index 000000000..a464b8d45
--- /dev/null
+++ b/app/javascript/controllers/bridge/text_size_controller.js
@@ -0,0 +1,25 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+
+export default class extends BridgeComponent {
+ static component = "text-size"
+
+ connect() {
+ super.connect()
+ this.notifyBridgeOfConnect()
+ }
+
+ disconnect() {
+ super.disconnect()
+ this.send("disconnect")
+ }
+
+ notifyBridgeOfConnect() {
+ this.send("connect", {}, message => {
+ this.#setTextSize(message.data)
+ })
+ }
+
+ #setTextSize(data) {
+ document.documentElement.dataset.textSize = data.textSize
+ }
+}
diff --git a/app/javascript/controllers/bridge/title_controller.js b/app/javascript/controllers/bridge/title_controller.js
new file mode 100644
index 000000000..f2226be35
--- /dev/null
+++ b/app/javascript/controllers/bridge/title_controller.js
@@ -0,0 +1,63 @@
+import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
+import { viewport } from "helpers/bridge/viewport_helpers"
+import { nextFrame } from "helpers/timing_helpers"
+
+export default class extends BridgeComponent {
+ static component = "title"
+ static targets = [ "header" ]
+ static values = { title: String }
+
+ async connect() {
+ super.connect()
+ await nextFrame()
+ this.#startObserver()
+ window.addEventListener("resize", this.#windowResized)
+ }
+
+ disconnect() {
+ super.disconnect()
+ this.#stopObserver()
+ window.removeEventListener("resize", this.#windowResized)
+ }
+
+ notifyBridgeOfVisibilityChange(visible) {
+ this.send("visibility", { title: this.#title, elementVisible: visible })
+ }
+
+ // Intersection Observer
+
+ #startObserver() {
+ if (!this.hasHeaderTarget) return
+
+ this.observer = new IntersectionObserver(([ entry ]) =>
+ this.notifyBridgeOfVisibilityChange(entry.isIntersecting),
+ { rootMargin: `-${this.#topOffset}px 0px 0px 0px` }
+ )
+
+ this.observer.observe(this.headerTarget)
+ this.previousTopOffset = this.#topOffset
+ }
+
+ #stopObserver() {
+ this.observer?.disconnect()
+ }
+
+ #updateObserverIfNeeded() {
+ if (this.#topOffset === this.previousTopOffset) return
+
+ this.#stopObserver()
+ this.#startObserver()
+ }
+
+ #windowResized = () => {
+ this.#updateObserverIfNeeded()
+ }
+
+ get #title() {
+ return this.titleValue ? this.titleValue : document.title
+ }
+
+ get #topOffset() {
+ return viewport.top
+ }
+}
diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js
index 714977c66..b8b7f6545 100644
--- a/app/javascript/controllers/bubble_controller.js
+++ b/app/javascript/controllers/bubble_controller.js
@@ -52,8 +52,11 @@ export default class extends Controller {
this.#show()
}
+ // Keep in sync with Card::Stallable#stalled? in app/models/card/stallable.rb
get #isStalled() {
- return this.stalledValue.lastActivitySpikeAt && signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays
+ return this.stalledValue.lastActivitySpikeAt &&
+ signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays &&
+ signedDifferenceInDays(new Date(this.stalledValue.updatedAt), new Date()) > this.stalledValue.stalledAfterDays
}
#showStalled() {
diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js
index 564c076ad..62d02288d 100644
--- a/app/javascript/controllers/card_hotkeys_controller.js
+++ b/app/javascript/controllers/card_hotkeys_controller.js
@@ -79,7 +79,7 @@ export default class extends Controller {
const selection = this.#selectedCard
if (!selection) return
- const url = selection.card.dataset.cardAssignToMeUrl
+ const url = selection.card.dataset.cardAssignToSelfUrl
if (url) {
event.preventDefault()
await post(url, { responseKind: "turbo-stream" })
diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js
index f6449cea7..037c5ec7b 100644
--- a/app/javascript/controllers/dialog_controller.js
+++ b/app/javascript/controllers/dialog_controller.js
@@ -1,8 +1,9 @@
import { Controller } from "@hotwired/stimulus"
import { orient } from "helpers/orientation_helpers"
+import { isTouchDevice } from "helpers/platform_helpers"
export default class extends Controller {
- static targets = [ "dialog" ]
+ static targets = [ "dialog", "focusMouse", "focusTouch" ]
static values = {
modal: { type: Boolean, default: false },
sizing: { type: Boolean, default: true },
@@ -14,6 +15,10 @@ export default class extends Controller {
if (this.autoOpenValue) this.open()
}
+ focusTouchTargetConnected() {
+ this.#setupFocus()
+ }
+
open() {
const modal = this.modalValue
@@ -63,4 +68,10 @@ export default class extends Controller {
captureKey(event) {
if (event.key !== "Escape") { event.stopPropagation() }
}
+
+ #setupFocus() {
+ const touch = isTouchDevice()
+ if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch
+ if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch
+ }
}
diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js
index beca1f08f..2ba58ab89 100644
--- a/app/javascript/controllers/form_controller.js
+++ b/app/javascript/controllers/form_controller.js
@@ -54,6 +54,10 @@ export default class extends Controller {
}
submitToTopTarget(event) {
+ const value = event.target.value?.trim()
+
+ if (!value) return false
+
this.element.setAttribute("data-turbo-frame", "_top")
this.submit()
}
diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js
index 02fce86d7..7104cfa8b 100644
--- a/app/javascript/controllers/notifications_controller.js
+++ b/app/javascript/controllers/notifications_controller.js
@@ -11,8 +11,7 @@ export default class extends Controller {
switch(Notification.permission) {
case "default":
- this.subscribeButtonTarget.hidden = false
- this.explainerTarget.hidden = true
+ this.#showButtonToSubscribe()
break
case "granted":
const registration = await this.#getServiceWorkerRegistration()
@@ -20,6 +19,8 @@ export default class extends Controller {
if (registration && subscription) {
this.element.classList.add(this.enabledClass)
+ } else {
+ this.#showButtonToSubscribe()
}
break
}
@@ -54,8 +55,9 @@ export default class extends Controller {
return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
}
- #registerServiceWorker() {
- return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ async #registerServiceWorker() {
+ await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ return navigator.serviceWorker.ready
}
async #subscribe(registration) {
@@ -76,6 +78,11 @@ export default class extends Controller {
}
}
+ #showButtonToSubscribe() {
+ this.subscribeButtonTarget.hidden = false
+ this.explainerTarget.hidden = true
+ }
+
async #requestPermissionAndSubscribe(registration) {
const permission = await Notification.requestPermission()
if (permission === "granted") this.#subscribe(registration)
diff --git a/app/javascript/helpers/bridge/viewport_helpers.js b/app/javascript/helpers/bridge/viewport_helpers.js
new file mode 100644
index 000000000..559143607
--- /dev/null
+++ b/app/javascript/helpers/bridge/viewport_helpers.js
@@ -0,0 +1,24 @@
+let top = 0
+const viewportTarget = window.visualViewport || window
+
+export const viewport = {
+ get top() {
+ return top
+ },
+ get height() {
+ return viewportTarget.height || window.innerHeight
+ }
+}
+
+function update() {
+ requestAnimationFrame(() => {
+ const styles = getComputedStyle(document.documentElement)
+ const customInset = styles.getPropertyValue("--custom-safe-inset-top")
+ const fallbackInset = styles.getPropertyValue("--safe-area-inset-top")
+ const insetValue = (customInset || fallbackInset).trim()
+ top = parseInt(insetValue || "0", 10) || 0
+ })
+}
+
+viewportTarget.addEventListener("resize", update)
+update()
diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js
new file mode 100644
index 000000000..7ddeb1b33
--- /dev/null
+++ b/app/javascript/initializers/bridge/bridge_element.js
@@ -0,0 +1,18 @@
+import { BridgeElement } from "@hotwired/hotwire-native-bridge"
+
+BridgeElement.prototype.getButton = function() {
+ return {
+ title: this.title,
+ icon: this.getIcon()
+ }
+}
+
+BridgeElement.prototype.getIcon = function() {
+ const url = this.bridgeAttribute(`icon-url`)
+
+ if (url) {
+ return { url }
+ }
+
+ return null
+}
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 10fb36975..90ef36a26 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1 +1,2 @@
import "initializers/current"
+import "initializers/bridge/bridge_element"
diff --git a/app/jobs/card/clean_inaccessible_data_job.rb b/app/jobs/card/clean_inaccessible_data_job.rb
new file mode 100644
index 000000000..c3fbef936
--- /dev/null
+++ b/app/jobs/card/clean_inaccessible_data_job.rb
@@ -0,0 +1,7 @@
+class Card::CleanInaccessibleDataJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
+ def perform(card)
+ card.clean_inaccessible_data
+ end
+end
diff --git a/app/models/card.rb b/app/models/card.rb
index 18957cb9b..d422afa85 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,13 +1,13 @@
class Card < ApplicationRecord
- include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
- Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
+ include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable,
+ Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :comments, dependent: :destroy
+ has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
has_one_attached :image, dependent: :purge_later
has_rich_text :description
@@ -23,7 +23,7 @@ class Card < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
- scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
+ scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
scope :indexed_by, ->(index) do
case index
@@ -46,12 +46,6 @@ class Card < ApplicationRecord
end
end
- delegate :accessible_to?, to: :board
-
- def publicly_accessible?
- published? && board.publicly_accessible?
- end
-
def card
self
end
@@ -72,55 +66,6 @@ class Card < ApplicationRecord
end
private
- STORAGE_BATCH_SIZE = 1000
-
- # Override to include comments, but only load comments that have attachments.
- # Cards can have thousands of comments; most won't have attachments.
- # Streams in batches to avoid loading all IDs into memory at once.
- def storage_transfer_records
- comment_ids_with_attachments = storage_comment_ids_with_attachments
-
- if comment_ids_with_attachments.any?
- [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
- else
- [ self ]
- end
- end
-
- def storage_comment_ids_with_attachments
- direct = []
- rich_text_map = {}
-
- # Stream comment IDs in batches to avoid loading all into memory
- comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
- batch_ids = batch.pluck(:id)
-
- direct.concat \
- ActiveStorage::Attachment
- .where(record_type: "Comment", record_id: batch_ids)
- .distinct
- .pluck(:record_id)
-
- ActionText::RichText
- .where(record_type: "Comment", record_id: batch_ids)
- .pluck(:id, :record_id)
- .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
- end
-
- embed_comment_ids = if rich_text_map.any?
- rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
- ActiveStorage::Attachment
- .where(record_type: "ActionText::RichText", record_id: batch_ids)
- .distinct
- .pluck(:record_id)
- end.filter_map { |rt_id| rich_text_map[rt_id] }
- else
- []
- end
-
- (direct + embed_comment_ids).uniq
- end
-
def set_default_title
self.title = "Untitled" if title.blank?
end
@@ -135,16 +80,13 @@ class Card < ApplicationRecord
end
remove_inaccessible_notifications_later
+ clean_inaccessible_data_later
end
def track_board_change_event(old_board_name)
track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name }
end
- def grant_access_to_assignees
- board.accesses.grant_to(assignees)
- end
-
def assign_number
self.number ||= account.increment!(:cards_count).cards_count
end
diff --git a/app/models/card/accessible.rb b/app/models/card/accessible.rb
new file mode 100644
index 000000000..e74e0b914
--- /dev/null
+++ b/app/models/card/accessible.rb
@@ -0,0 +1,26 @@
+module Card::Accessible
+ extend ActiveSupport::Concern
+
+ included do
+ delegate :accessible_to?, to: :board
+ end
+
+ def publicly_accessible?
+ published? && board.publicly_accessible?
+ end
+
+ def clean_inaccessible_data
+ accessible_user_ids = board.accesses.pluck(:user_id)
+ pins.where.not(user_id: accessible_user_ids).in_batches.destroy_all
+ watches.where.not(user_id: accessible_user_ids).in_batches.destroy_all
+ end
+
+ private
+ def grant_access_to_assignees
+ board.accesses.grant_to(assignees)
+ end
+
+ def clean_inaccessible_data_later
+ Card::CleanInaccessibleDataJob.perform_later(self)
+ end
+end
diff --git a/app/models/card/commentable.rb b/app/models/card/commentable.rb
new file mode 100644
index 000000000..4296a77a7
--- /dev/null
+++ b/app/models/card/commentable.rb
@@ -0,0 +1,61 @@
+module Card::Commentable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :comments, dependent: :destroy
+ end
+
+ def commentable?
+ published?
+ end
+
+ private
+ STORAGE_BATCH_SIZE = 1000
+
+ # Override to include comments, but only load comments that have attachments.
+ # Cards can have thousands of comments; most won't have attachments.
+ # Streams in batches to avoid loading all IDs into memory at once.
+ def storage_transfer_records
+ comment_ids_with_attachments = storage_comment_ids_with_attachments
+
+ if comment_ids_with_attachments.any?
+ [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
+ else
+ [ self ]
+ end
+ end
+
+ def storage_comment_ids_with_attachments
+ direct = []
+ rich_text_map = {}
+
+ # Stream comment IDs in batches to avoid loading all into memory
+ comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
+ batch_ids = batch.pluck(:id)
+
+ direct.concat \
+ ActiveStorage::Attachment
+ .where(record_type: "Comment", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+
+ ActionText::RichText
+ .where(record_type: "Comment", record_id: batch_ids)
+ .pluck(:id, :record_id)
+ .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
+ end
+
+ embed_comment_ids = if rich_text_map.any?
+ rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
+ ActiveStorage::Attachment
+ .where(record_type: "ActionText::RichText", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+ end.filter_map { |rt_id| rich_text_map[rt_id] }
+ else
+ []
+ end
+
+ (direct + embed_comment_ids).uniq
+ end
+end
diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb
index 8cc108b23..d5f53205d 100644
--- a/app/models/card/searchable.rb
+++ b/app/models/card/searchable.rb
@@ -25,4 +25,8 @@ module Card::Searchable
def search_board_id
board_id
end
+
+ def searchable?
+ published?
+ end
end
diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb
index 03348dfde..9e82b3fa1 100644
--- a/app/models/card/stallable.rb
+++ b/app/models/card/stallable.rb
@@ -13,6 +13,7 @@ module Card::Stallable
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
end
+ # Keep in sync with #isStalled in app/javascript/controllers/bubble_controller.js
def stalled?
if activity_spike.present?
open? && last_activity_spike_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago && updated_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 30706e8b5..70a36562f 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -4,10 +4,12 @@ class Comment < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :reactions, -> { order(:created_at) }, dependent: :delete_all
+ has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
has_rich_text :body
+ validate :card_is_commentable
+
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
@@ -22,6 +24,10 @@ class Comment < ApplicationRecord
end
private
+ def card_is_commentable
+ errors.add(:card, "does not allow comments") unless card.commentable?
+ end
+
def watch_card_by_creator
card.watch_by creator
end
diff --git a/app/models/comment/searchable.rb b/app/models/comment/searchable.rb
index 626d2be64..3194f9042 100644
--- a/app/models/comment/searchable.rb
+++ b/app/models/comment/searchable.rb
@@ -20,4 +20,8 @@ module Comment::Searchable
def search_board_id
card.board_id
end
+
+ def searchable?
+ card.published?
+ end
end
diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb
index 8b39504ef..2be34fe8d 100644
--- a/app/models/concerns/searchable.rb
+++ b/app/models/concerns/searchable.rb
@@ -1,6 +1,8 @@
module Searchable
extend ActiveSupport::Concern
+ SEARCH_CONTENT_LIMIT = 32.kilobytes
+
included do
after_create_commit :create_in_search_index
after_update_commit :update_in_search_index
@@ -13,11 +15,17 @@ module Searchable
private
def create_in_search_index
- search_record_class.create!(search_record_attributes)
+ if searchable?
+ search_record_class.create!(search_record_attributes)
+ end
end
def update_in_search_index
- search_record_class.upsert!(search_record_attributes)
+ if searchable?
+ search_record_class.upsert!(search_record_attributes)
+ else
+ remove_from_search_index
+ end
end
def remove_from_search_index
@@ -32,11 +40,15 @@ module Searchable
card_id: search_card_id,
board_id: search_board_id,
title: search_title,
- content: search_content,
+ content: search_record_content,
created_at: created_at
}
end
+ def search_record_content
+ search_content&.truncate_bytes(SEARCH_CONTENT_LIMIT, omission: "")
+ end
+
def search_record_class
Search::Record.for(account_id)
end
@@ -47,4 +59,5 @@ module Searchable
# - search_content: returns content string
# - search_card_id: returns the card id (self.id for cards, card_id for comments)
# - search_board_id: returns the board id
+ # - searchable?: returns whether this record should be indexed
end
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
index 76edf152e..be5fc5d7e 100644
--- a/app/models/reaction.rb
+++ b/app/models/reaction.rb
@@ -1,6 +1,6 @@
class Reaction < ApplicationRecord
- belongs_to :account, default: -> { comment.account }
- belongs_to :comment, touch: true
+ belongs_to :account, default: -> { reactable.account }
+ belongs_to :reactable, polymorphic: true, touch: true
belongs_to :reacter, class_name: "User", default: -> { Current.user }
scope :ordered, -> { order(:created_at) }
@@ -11,6 +11,6 @@ class Reaction < ApplicationRecord
private
def register_card_activity
- comment.card.touch_last_active_at
+ reactable.card.touch_last_active_at
end
end
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb
index 74e274414..438560635 100644
--- a/app/views/account/settings/show.html.erb
+++ b/app/views/account/settings/show.html.erb
@@ -1,7 +1,7 @@
<% @page_title = "Account Settings" %>
<% content_for :header do %>
-