Merge branch 'main' into mobile/prepare-webviews-2

* main: (100 commits)
  Fix notification broadcast test for turbo-rails 2.0.21
  Update `turbo-rails` to get latest Turbo version
  Remove reference to removed controller
  Fix bad formatting on bridge page title attr
  Use private functions for bridge components
  Setup focus handling on touch target's connect callback
  Setup proper focus handling for mobile on the card perma's board picker
  Move dialog focus handling into the dialog controller
  Create popup initial alignment classes
  Fix class typo
  Hide the board selector button if card is closed
  Align board and tag picker dialogs without using math
  Add dialog manager to card perma
  Fix "M" hotkey using stale user from fragment cache
  Solve problem when Action Text raises on missing attachables
  Solve problem when Action Text raises on missing attachables
  Completing a step removes stalled status from UI
  Bump Bootsnap to v1.21.1
  Add Enable all/Disable all buttons to webhook event selection
  Use correct class selectors to target cards with  background images
  ...
This commit is contained in:
Adrien Maston
2026-01-20 13:38:38 +01:00
114 changed files with 1366 additions and 252 deletions
+4 -4
View File
@@ -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)
+4 -4
View File
@@ -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)
+15 -10
View File
@@ -378,6 +378,7 @@
}
.cards__menu {
position: relative;
z-index: var(--z-popup);
}
@@ -585,17 +586,28 @@
}
}
.card:has(.card__background img:not([src=""])) {
&:has(.card__background img:not([src=""])) {
.card__content,
.card__meta,
.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__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 +616,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
+18 -24
View File
@@ -119,17 +119,6 @@
}
}
.board-picker__button {
--btn-border-size: 0;
font-size: 0.5em;
margin-inline-start: 1ch;
+ .panel {
--panel-size: 18ch;
}
}
.bubble {
--bubble-number-max: 42px;
--bubble-size: 6rem;
@@ -292,10 +281,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 +294,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 +321,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));
}
}
}
}
+54 -9
View File
@@ -71,21 +71,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 +97,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 +109,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 {
+159 -5
View File
@@ -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 {
+40
View File
@@ -1,5 +1,7 @@
@layer native {
[data-platform~=native] {
--footer-height: 0;
-webkit-tap-highlight-color: transparent;
.hide-on-native {
@@ -27,11 +29,22 @@
}
}
/* 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__bg {
@@ -39,6 +52,11 @@
border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block)));
}
.card-perma__closure-message {
margin-block: var(--block-space);
translate: unset;
}
/* Search
/* ------------------------------------------------------------------------ */
@@ -47,3 +65,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;
}
}
+14 -1
View File
@@ -21,11 +21,16 @@
display: var(--popup-display);
}
/* The .pop-up--align-<drection> classes are used for initial alignment.
* The orient JS helper layers on the .orient-<direction> 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;
}
+2 -2
View File
@@ -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;
@@ -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
-33
View File
@@ -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;
}
}
}
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ class Users::AvatarsController < ApplicationController
if @user.system?
redirect_to view_context.image_path("system_user.png")
elsif @user.avatar.attached?
redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline")
redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline")
elsif stale? @user, cache_control: cache_control
render_initials
end
+2 -1
View File
@@ -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
+1 -1
View File
@@ -42,6 +42,6 @@ module AvatarsHelper
end
def avatar_image_tag(user, **options)
image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options
image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options
end
end
+2 -1
View File
@@ -4,7 +4,8 @@ module BoardsHelper
end
def link_to_edit_board(board)
link_to edit_board_path(board), class: "btn btn--circle-mobile", data: { controller: "tooltip" } do
link_to edit_board_path(board), class: "btn btn--circle-mobile",
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
+5
View File
@@ -0,0 +1,5 @@
module BridgeHelper
def bridge_icon(name)
asset_url("#{name}.svg")
end
end
+1
View File
@@ -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
+4
View File
@@ -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
+19
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ module WebhooksHelper
def link_to_webhooks(board, &)
link_to board_webhooks_path(board_id: board),
class: [ "btn btn--circle-mobile", { "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
+1 -1
View File
@@ -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"
@@ -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)
}
}
@@ -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" ]
})
}
}
@@ -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`)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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() {
@@ -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" })
@@ -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
}
}
@@ -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()
@@ -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
}
+1
View File
@@ -1 +1,2 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
@@ -0,0 +1,7 @@
class Card::CleanInaccessibleDataJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.clean_inaccessible_data
end
end
+2 -11
View File
@@ -1,5 +1,5 @@
class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
@@ -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
@@ -135,16 +129,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
+26
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
<% @page_title = "Account Settings" %>
<% content_for :header do %>
<h1 class="header__title">
<h1 class="header__title" data-bridge--title-target="header">
<%= @page_title %>
<% unless Current.user.admin? %>
<div class="txt-normal font-weight-normal">Only admins can change these settings</div>
@@ -12,14 +12,14 @@
height: height %>
<% elsif blob.audio? %>
<audio controls="true" width="100%" preload="metadata">
<source src="<%= rails_blob_url(blob) %>" type="<%= blob.content_type %>">
<source src="<%= rails_blob_path(blob) %>" type="<%= blob.content_type %>">
</audio>
<% elsif blob.variable? %>
<%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %>
<%= image_tag url_for(blob.variant(variant)), width: width, height: height %>
<%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %>
<%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %>
<% end %>
<% elsif blob.previewable? %>
<%= image_tag url_for(blob.preview(variant)), width: width, height: height %>
<%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %>
<% else %>
<span class="attachment__icon"><%= blob.filename.extension&.downcase.presence || "unknown" %></span>
<% end %>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= link_back_to_board(@column.board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title">
<h1 class="header__title" data-bridge--title-target="header">
<div><%= @page_title %></div>
<% unless Current.user.can_administer_board?(@board) %>
<div class="txt-normal font-weight-normal">Only admins can change these settings</div>
+2 -2
View File
@@ -1,11 +1,11 @@
<% @page_title = "Create a new board" %>
<div class="panel panel--centered">
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
<%= 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" } %>
<button type="submit" class="btn btn--link center">
<button type="submit" class="btn btn--link center" data-bridge--form-target="submit">
<span>Create board</span>
<%= icon_tag "arrow-right" %>
</button>
+3 -3
View File
@@ -5,15 +5,15 @@
<%= turbo_stream_from @board %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<div class="header__actions header__actions--start hide-on-native">
<%= link_to_webhooks(@board) if Current.user.admin? %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @board.name %></span>
</h1>
<div class="header__actions header__actions--end">
<div class="header__actions header__actions--end hide-on-native">
<%= link_to_edit_board @board %>
</div>
<% end %>
+3 -1
View File
@@ -7,7 +7,9 @@
<%= render "boards/show/expander", title: "Maybe?", count: board.cards.awaiting_triage.count, column_id: "maybe" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_stream_path(board) %>
</header>
<%= render "columns/show/add_card_button", board: board %>
<div data-bridge-disabled="true">
<%= render "columns/show/add_card_button", board: board %>
</div>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if page.used? %>
<%= with_automatic_pagination "maybe", @page do %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= icon_tag "menu-dots-horizontal", class: "translucent" %>
<span class="for-screen-reader">Column options</span>
</button>
<dialog class="popup panel flex-column gap fill-white shadow txt-small margin-block-double" data-dialog-target="dialog">
<dialog class="popup popup--align-right panel flex-column gap fill-white shadow txt-small margin-block-double" data-dialog-target="dialog">
<ul class="popup__list">
<li class="popup__item">
<button class="popup__btn btn" data-action="click->clicker#click">
+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" style="--card-color: <%= card.color %>;" data-controller="dialog-manager bridge--form">
<% cache card do %>
<div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %>
+2 -2
View File
@@ -5,10 +5,10 @@
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
navigable_list_actionable_items_value: true } do %>
<strong class="popup__title pad-inline-half">Move this card to…</strong>
<%= filter_title "Move this card to…" %>
<%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half font-weight-normal", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<ul class="popup__list margin-block-start-half margin-none-block-end" data-filter-target="list">
<% @boards.each do |board| %>
+6 -10
View File
@@ -1,17 +1,13 @@
<div class="display-contents" id="<%= dom_id(card, :card_closure_toggle) %>">
<% if card.closed? %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="flex gap-half margin-block-start-double margin-block-end">
<span class="card-perma__closure-message">
Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
</span>
</div>
<div class="card-perma__closure-message flex gap-half justify-center">
<span>Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.</span>
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
</div>
<% else %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="card-perma__notch card-perma__notch--bottom">
<%= render "cards/container/closure_buttons", card: card %>
</div>
<% if card.entropic? && card.open? && !card.postponed? %>
@@ -1,12 +1,13 @@
<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", turbo_frame: dom_id(card, :edit) } do %>
<%= 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 %>
<%= icon_tag "pencil", class: "icon--mobile-only" %>
<span>Edit</span>
<kbd class="txt-x-small hide-on-touch">e</kbd>
<% end %>
<%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
data: { controller: "hotkey", form_target: "submit", action: "keydown.d@document->hotkey#click" },
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
form: { data: { controller: "form" } } do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
<span class="overflow-ellipsis">Mark as Done</span>
+4 -2
View File
@@ -1,10 +1,12 @@
<% if card.golden? %>
<%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %>
<%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Demote to normal" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Demote to normal (shift+g)</span>
<% end %>
<% else %>
<%= button_to card_goldness_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %>
<%= button_to card_goldness_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Promote to Golden Ticket" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
<% end %>
+3 -2
View File
@@ -1,11 +1,12 @@
<% if card.image.attached? %>
<%= button_to card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip" } do %>
<%= button_to card_image_path(card), method: :delete, class: "btn",
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %>
<%= icon_tag "picture-remove" %>
<span class="for-screen-reader">Remove background image</span>
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, data: { controller: "form" } do |form| %>
<label class="card-perma__image-btn btn input--file btn--circle-mobile" data-controller="tooltip">
<label class="card-perma__image-btn btn input--file btn--circle-mobile" data-controller="tooltip" data-bridge--overflow-menu-target="item" data-bridge-title="Add background image">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
@@ -1,7 +1,7 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<%= button_tag type: :submit, form: dom_id(card, :edit_form), class: "btn borderless",
title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })",
data: { controller: "hotkey", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
data: { controller: "hotkey", bridge__form_target: "submit", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
<span>Save changes</span>
<% end %>
</div>
@@ -2,8 +2,8 @@
<div class="card-perma__notch-new-card-buttons">
<%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn",
title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })",
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
form: { data: { controller: "form bridge--form" } },
data: { form_target: "submit", bridge__form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
+1 -3
View File
@@ -11,9 +11,7 @@
<% card_data[:card_not_now_url] = card_not_now_path(card) %>
<% card_data[:card_closure_url] = card_closure_path(card) %>
<% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %>
<% if Current.user %>
<% card_data[:card_assign_to_me_url] = card_assignments_path(card, params: { assignee_id: Current.user.id }) %>
<% end %>
<% card_data[:card_assign_to_self_url] = card_self_assignment_path(card) %>
<% end %>
<%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
@@ -1,10 +1,8 @@
<label class="card__board">
<span class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
</span>
<span class="card__board-name">
<span class="overflow-ellipsis"><%= card.board.name %></span>
</span>
<div class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
</div>
<div class="card__board-name">
<span class="overflow-ellipsis"><%= card.board.name %></span>
<%= yield %>
</label>
</div>
+9 -14
View File
@@ -1,14 +1,9 @@
<%= render "cards/display/common/board", card: card do %>
<div class="align-self-center position-relative txt-small"
data-controller="dialog"
data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="board-picker__button btn btn--reversed card__hide-on-index fill-transparent" data-action="click->dialog#open:stop">
<%= icon_tag "caret-down" %>
<span class="for-screen-reader">Choose a board for this card</span>
</button>
<dialog class="popup panel flex-column align-start gap-half fill-white shadow margin-block-double" data-dialog-target="dialog" data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close">
<%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %>
</dialog>
</div>
<% end %>
<div class="card__board" data-controller="dialog" data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames">
<%= render "cards/display/common/board", card: card do %>
<%= icon_tag "caret-down", class: "txt-xx-small", hidden: card.closed? %>
<% end %>
<button class="card__board-picker-button btn btn--plain" data-action="click->dialog#open:stop" aria-label="Choose a board for this card" <%= "hidden" if card.closed? %>></button>
<dialog class="popup popup--align-right panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close">
<%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %>
</dialog>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<div id="<%= dom_id(card, :tags) %>" class="card__tags">
<div class="position-relative" data-controller="dialog"
<div data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="tag-picker__button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
<button class="card__tag-picker-button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
data-controller="tooltip hotkey" data-action="click->dialog#open:stop keydown.t@document->hotkey#click">
<%= icon_tag "tag" %>
<span class="for-screen-reader">Add a tag</span>
@@ -1 +1,3 @@
<%= render "cards/display/common/board", card: card %>
<div class="card__board">
<%= render "cards/display/common/board", card: card %>
</div>
+2 -1
View File
@@ -22,6 +22,7 @@
<%= general_prompts(@card.board) %>
<% end %>
<%= link_to "Close editor and discard changes", @card, data: { form_target: "cancel" }, hidden: true %>
<%= link_to "Close editor and discard changes", @card,
data: { form_target: "cancel", bridge__form_target: "cancel", bridge_title: "Cancel" }, hidden: true %>
<% end %>
<% end %>
+1 -1
View File
@@ -4,7 +4,7 @@
<%= render "cards/broadcasts", filter: @filter %>
<% content_for :header do %>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis txt-capitalize-first-letter"><%= @user_filtering.selected_boards_label %></span>
</h1>
+4 -2
View File
@@ -1,10 +1,12 @@
<div id="<%= dom_id(card, :pin_button) %>">
<% if card.pinned_by? Current.user %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click" } do %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Un-pin this card" } do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card (shift+p)</span>
<% end %>
<% else %>
<%= button_to card_pin_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click" } do %>
<%= button_to card_pin_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Pin this card" } do %>
<%= icon_tag "unpinned" %> <span class="for-screen-reader">Pin this card (shift+p)</span>
<% end %>
<% end %>
@@ -1,3 +1,7 @@
<%= turbo_stream.replace @step do %>
<%= render "cards/steps/step", step: @step %>
<% end %>
<%= turbo_stream.replace dom_id(@card, "bubble") do %>
<%= render "cards/display/preview/bubble", card: @card %>
<% end %>
@@ -1,11 +1,13 @@
<div id="<%= dom_id(card, :watch_button) %>">
<% if card.watched_by? Current.user %>
<%= button_to card_watch_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click" } do %>
<%= button_to card_watch_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Stop watching" } do %>
<%= icon_tag "bell" %>
<span class="for-screen-reader">Stop watching (shift+n)</span>
<% end %>
<% else %>
<%= button_to card_watch_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click" } do %>
<%= button_to card_watch_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Watch this" } do %>
<%= icon_tag "bell-off" %>
<span class="for-screen-reader">Watch this (shift+n)</span>
<% end %>
@@ -1,5 +1,7 @@
<div class="board-tools card">
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, data: { controller: "hotkey", action: "keydown.c@document->hotkey#click" } do %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link",
form: { data: { turbo_frame: "_top" } },
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add", class: "show-on-touch" %>
<span>Add a card</span>
<kbd class="hide-on-touch">C</kbd>
@@ -5,7 +5,7 @@
<%= back_link_to "Activity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title">
<h1 class="header__title" data-bridge--title-target="header">
<%= @column.title %>
</h1>
<% end %>
@@ -3,9 +3,9 @@
<% height = attachment.metadata["height"] %>
<% if attachment.previewable? %>
<%= image_tag url_for(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %>
<%= image_tag rails_representation_path(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %>
<% elsif attachment.variable? %>
<%= image_tag url_for(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %>
<%= image_tag rails_representation_path(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %>
<% else %>
<div class="attachment attachment--file attachment--<%= attachment.filename.extension -%>">
<span class="attachment__icon"><%= attachment.filename.extension&.downcase.presence || "unknown" %></span>
+1 -1
View File
@@ -6,7 +6,7 @@
<% content_for :header do %>
<%= render "events/index/add_card_button", user_filtering: @user_filtering %>
<h1 class="header__title" data-controller="dialog-manager">
<h1 class="header__title" data-controller="dialog-manager" data-bridge--title-target="header">
<% if @user_filtering.boards.many? %>
<span>Activity <%= @user_filtering.filter.boards.any? ? "in" : "across" %></span>
<% else %>
@@ -1,6 +1,7 @@
<div class="header__actions header__actions--end">
<div>
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.b@document->hotkey#click" } do %>
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a board", bridge_icon_url: bridge_icon("board") } do %>
<%= icon_tag "board" %>
<span class="overflow-ellipsis">Add a board</span>
<kbd class="hide-on-touch">B</kbd>
@@ -1,6 +1,7 @@
<div class="header__actions header__actions--start">
<% if board = user_filtering.single_board_or_first %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.c@document->hotkey#click" } do %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a card", bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add" %>
<span class="overflow-ellipsis">Add a card</span>
<kbd class="hide-on-touch">C</kbd>
@@ -16,9 +16,9 @@
</template>
<%= filter_dialog "Board…" do %>
<strong class="popup__title">Board…</strong>
<%= filter_title "Board…" %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small font-weight-normal", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<ul class="popup__list" data-filter-target="list" role="listbox">
<% user_filtering.boards.each do |board| %>
+2 -2
View File
@@ -16,9 +16,9 @@
</template>
<%= filter_dialog "Person…" do %>
<strong class="popup__title">Person…</strong>
<%= filter_title "Person…" %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small font-weight-normal", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<ul class="popup__list" data-filter-target="list" role="listbox">
<% user_filtering.users.each do |user| %>
@@ -16,11 +16,10 @@
</template>
<%= filter_dialog "Assigned to…" do %>
<strong class="popup__title">Assigned to…</strong>
<%= filter_title "Assigned to…" %>
<% if Current.account.users.active.many? %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list" role="listbox">
+2 -3
View File
@@ -17,11 +17,10 @@
</template>
<%= filter_dialog "Board…" do %>
<strong class="popup__title">Board…</strong>
<%= filter_title "Board…" %>
<% if user_filtering.boards.many? %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list" role="listbox">
+2 -3
View File
@@ -17,11 +17,10 @@
</template>
<%= filter_dialog "Closed by…" do %>
<strong class="popup__title">Closed by…</strong>
<%= filter_title "Closed by…" %>
<% if user_filtering.users.many? %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list" role="listbox">
@@ -17,11 +17,10 @@
</template>
<%= filter_dialog "Added by…" do %>
<strong class="popup__title">Added by…</strong>
<%= filter_title "Added by…" %>
<% if user_filtering.users.many? %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list" role="listbox">
+2 -3
View File
@@ -15,11 +15,10 @@
</template>
<%= filter_dialog "Tagged…" do %>
<strong class="popup__title">Tagged…</strong>
<%= filter_title "Tagged…" %>
<% if user_filtering.tags.many? %>
<%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list" role="listbox">
+5 -2
View File
@@ -2,8 +2,11 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body class="<%= @body_class %>" data-controller="local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation" data-platform="<%= platform.type %>">
<div id="global-container">
<body class="<%= @body_class %>"
data-controller="local-time timezone-cookie turbo-navigation theme bridge--title bridge--text-size bridge--insets"
data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation"
data-platform="<%= platform.type %>" data-bridge--title-title-value="<%= @page_title %>">
<div id="global-container" data-controller="bridge--buttons bridge--overflow-menu">
<header class="header header--mobile-actions-stack <%= @header_class %>" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= render "my/menu" if Current.user %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center webhooks">
+1 -1
View File
@@ -5,7 +5,7 @@
<%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
+1 -1
View File
@@ -5,7 +5,7 @@
<%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
+1 -1
View File
@@ -3,7 +3,7 @@
<%= collapsible_nav_section "Accounts" do %>
<%# Bust cache 1 Dec 2025 %>
<% accounts.each do |account| %>
<%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %>
<%= filter_place_menu_item landing_path(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %>
<% end %>
<% end %>
<% end %>
+1 -1
View File
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<span>Sign out</span>
<% end %>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<div class="pins-list panel panel--wide flex center borderless unpad flex-column gap-half margin-block-start">
+2 -2
View File
@@ -6,10 +6,10 @@
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<%= link_to notifications_settings_path, class: "btn btn--circle-mobile" do %>
<%= link_to notifications_settings_path, class: "btn btn--circle-mobile", data: { bridge__overflow_menu_target: "item", bridge_title: "Notification settings" } do %>
<%= icon_tag "settings" %> <span class="for-screen-reader">Notification settings</span>
<% end %>
</div>
@@ -1,7 +1,7 @@
<% @page_title = "Notification Settings" %>
<% content_for :header do %>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="settings margin-block-start-half">
+1 -1
View File
@@ -3,6 +3,6 @@
#<%= card.number %> <%= card.title %>
</template>
<template type="editor">
<%= link_to "##{card.number} #{card.title}", card_url(card) %>
<%= link_to "##{card.number} #{card.title}", card_path(card) %>
</template>
</lexxy-prompt-item>
@@ -5,7 +5,7 @@
<%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= back_link_to @column.board.name, published_board_url(@column.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -14,7 +14,7 @@
<% end %>
<% content_for :header do %>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<div class="search-perma margin-block-start">
+1 -1
View File
@@ -13,7 +13,7 @@
<% @accounts.each do |account| %>
<li class="popup__item txt-medium">
<%= icon_tag "marker", class: "popup__icon" %>
<%= link_to landing_url(script_name: account.slug), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<%= link_to landing_path(script_name: account.slug), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= account.name %></strong>
<% end %>
</li>
+1 -1
View File
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
<% end %>
+24 -17
View File
@@ -1,17 +1,24 @@
<ul class="flex flex-column align-start gap-half margin-none unpad">
<%= form.collection_check_boxes \
:subscribed_actions,
webhook_action_options,
:first,
:last do |item| %>
<li class="list-style-none margin-none">
<label class="toggler__visible-when-off flex align-center gap cursor-pointer">
<span class="switch txt-x-small flex-item-no-shrink">
<%= item.check_box(class: "switch__input") %>
<span class="switch__btn round"></span>
</span>
<span class="min-width"><%= item.text %></span>
</label>
</li>
<% end %>
</ul>
<div data-controller="toggle-class">
<div class="flex align-center gap-half margin-block-end-half">
<%= button_tag "Enable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" } %>
<span class="txt-subtle">&middot;</span>
<%= button_tag "Disable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" } %>
</div>
<ul class="flex flex-column align-start gap-half margin-none unpad">
<%= form.collection_check_boxes \
:subscribed_actions,
webhook_action_options,
:first,
:last do |item| %>
<li class="list-style-none margin-none">
<label class="toggler__visible-when-off flex align-center gap cursor-pointer">
<span class="switch txt-x-small flex-item-no-shrink">
<%= item.check_box(class: "switch__input", data: { toggle_class_target: "checkbox" }) %>
<span class="switch__btn round"></span>
</span>
<span class="min-width"><%= item.text %></span>
</label>
</li>
<% end %>
</ul>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<%= tag.section class: "panel panel--wide shadow center webhooks" do %>

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