diff --git a/app/assets/images/funnel.svg b/app/assets/images/funnel.svg new file mode 100644 index 0000000000..26f3684d74 --- /dev/null +++ b/app/assets/images/funnel.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 24e5b87279..13975ae62d 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,6 +1,12 @@ @layer reset, base, components, modules, utilities, native, platform; :root { + /* Insets - The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ + --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); + --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); + --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)); + /* Spacing */ --inline-space: 1ch; --inline-space-half: calc(var(--inline-space) / 2); diff --git a/app/assets/stylesheets/android.css b/app/assets/stylesheets/android.css index f30158e8ef..7bf6c0085b 100644 --- a/app/assets/stylesheets/android.css +++ b/app/assets/stylesheets/android.css @@ -3,5 +3,12 @@ .hide-on-android { display: none; } + + /* Filters + /* ------------------------------------------------------------------------ */ + + .filters { + --text-x-small: 1rem; + } } } diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index ac897184fa..89638fc12d 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -107,11 +107,6 @@ > * { grid-area: 1/1; } - - @media (max-width: 639px) { - --btn-size: 3em; - --icon-size: 75%; - } } /* Make a normal button circular on mobile */ @@ -125,7 +120,7 @@ inline-size: var(--btn-size); kbd, - span:last-of-type { + span:last-of-type:not(.icon) { display: none; } } @@ -224,17 +219,15 @@ .btn--back { --btn-border-size: 0; - font-size: var(--text-medium); - @media (max-width: 639px) { - padding: 0.5em; - strong, kbd { display: none; } } @media (min-width: 640px) { + font-size: var(--text-medium); + .icon--arrow-left { display: none; } diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 1083c2ffc9..7cb14e15be 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -232,7 +232,7 @@ overflow-y: auto; .is-expanded & { - padding: var(--column-padding); + padding: var(--column-padding) var(--column-padding) calc(var(--column-padding) + var(--custom-safe-inset-bottom)); /* Use the rest of the column height for scrolling */ @media (max-width: 639px) { diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css index 5601b1988d..8e28654bbe 100644 --- a/app/assets/stylesheets/events.css +++ b/app/assets/stylesheets/events.css @@ -220,14 +220,14 @@ .events__column-header { background-color: var(--color-canvas); grid-row-start: 1; - inset-block-start: calc(var(--block-space) * -1); - margin-block-end: var(--events-gap); - padding-block: calc(var(--events-gap) * 3) var(--events-gap); + inset-block-start: var(--custom-safe-inset-top); + margin-block: calc(var(--events-gap) * 2) var(--events-gap); + padding-block: var(--events-gap); position: sticky; z-index: var(--z-events-column-header); @media (max-width: 639px) { - margin-inline: calc(var(--main-padding) * -1); + margin-inline: calc(var(--main-padding) * -0.5); padding-inline: var(--main-padding); } } @@ -239,12 +239,16 @@ } .events__maximize-button { - inset: calc(var(--events-gap) * 3) 0 auto auto; + inset: 50% var(--events-gap) auto auto; outline-offset: -2px; position: absolute; - transform: translateY(-10%); + transform: translateY(-50%); z-index: 1; + @media (max-width: 639px) { + inset-inline-end: 0; + } + @media (any-hover: hover ) { opacity: 0; diff --git a/app/assets/stylesheets/expandable.css b/app/assets/stylesheets/expandable.css new file mode 100644 index 0000000000..f2fe31c23e --- /dev/null +++ b/app/assets/stylesheets/expandable.css @@ -0,0 +1,13 @@ +@layer components { + .expandable-on-native { + body:not([data-platform~=native]) & { + &::details-content { + display: contents; + } + + summary { + display: none; + } + } + } +} diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css index 5a3c7ee835..c54636d131 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/header.css @@ -13,7 +13,7 @@ "menu menu menu" "actions-start title actions-end"; max-inline-size: 100dvw; - padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half); + padding-block: calc(var(--block-space-half) + var(--custom-safe-inset-top)) var(--block-space-half); padding-inline: var(--main-padding); position: relative; z-index: var(--z-nav); diff --git a/app/assets/stylesheets/ios.css b/app/assets/stylesheets/ios.css index 3a05e5956b..2242e7c770 100644 --- a/app/assets/stylesheets/ios.css +++ b/app/assets/stylesheets/ios.css @@ -1,7 +1,27 @@ @layer platform { + :root:has([data-platform~=ios]) { + &[data-text-size=xsmall] { font-size: 14px; } + &[data-text-size=small] { font-size: 15px; } + &[data-text-size=medium] { font-size: 16px; } + &[data-text-size=large] { font-size: 17px; } + &[data-text-size=xlarge] { font-size: 19px; } + &[data-text-size=xxlarge] { font-size: 21px; } + &[data-text-size=xxxlarge] { font-size: 23px; } + } + [data-platform~=ios] { .hide-on-ios { display: none; } + + /* Events + /* ------------------------------------------------------------------------ */ + + .events__column-header { + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + background-color: oklch(from var(--color-canvas) l c h / 0.5); + border-radius: 10em; + } } } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index 6ea479eb64..f789f7ceb7 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -6,6 +6,13 @@ &.public { grid-template-rows: auto 1fr auto; } + + &.compact-on-touch { + @media (any-hover: none) { + grid-template-rows: auto 1fr auto; + min-height: unset; + } + } } /* Required for the card column page on mobile, but not needed otherwise */ @@ -23,8 +30,8 @@ margin-inline: auto; max-inline-size: 100dvw; padding-inline: - calc(var(--main-padding) + env(safe-area-inset-left)) - calc(var(--main-padding) + env(safe-area-inset-right)); + calc(var(--main-padding) + var(--custom-safe-inset-left)) + calc(var(--main-padding) + var(--custom-safe-inset-right)); text-align: center; } diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 65144a22bd..b1b73af513 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -3,16 +3,23 @@ --dialog-duration: 350ms; --lightbox-padding: 3vmin; + align-items: center; background-color: transparent; block-size: 100dvh; border: 0; + display: flex; inline-size: 100dvw; inset: 0; + justify-content: center; margin: auto; max-height: unset; max-width: unset; overflow: hidden; - padding: var(--lightbox-padding); + padding: + calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-bottom)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-left)); text-align: center; &::backdrop { @@ -47,12 +54,15 @@ .lightbox__actions { display: flex; gap: 1ch; - inset: var(--lightbox-padding) var(--lightbox-padding) auto auto; + inset: + calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) + auto + auto; position: absolute; } .lightbox__figure { - align-self: stretch; animation-fill-mode: forwards; animation: slide-down var(--dialog-duration); display: flex; @@ -72,6 +82,10 @@ &:empty { display: none; } + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } .lightbox__image { diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 8226a52612..c67d05d149 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -1,11 +1,5 @@ @layer native { [data-platform~=native] { - /* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ - --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); - --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); - --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; @@ -14,6 +8,15 @@ display: none; } + /* Layout + /* ------------------------------------------------------------------------ */ + + &:not(.contained-scrolling) { + #main { + padding-block-end: var(--custom-safe-inset-bottom); + } + } + /* Header /* ------------------------------------------------------------------------ */ @@ -21,7 +24,12 @@ :not(:has(.header__title, .header__actions)), :not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child) ) { - display: none; + block-size: var(--custom-safe-inset-top); + padding: unset; + + * { + display: none; + } } .header__actions { @@ -48,6 +56,11 @@ } } + .card-perma__bg { + border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); + 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; diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index aa0b8c1daf..6e2720637c 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -27,13 +27,13 @@ &:where(.popup--align-left), &.orient-left { inset-inline: auto 0; - transform: translateX(0); + transform: translateX(var(--orient-offset, 0px)); } &:where(.popup--align-right), &.orient-right { inset-inline: 0 auto; - transform: translateX(0); + transform: translateX(var(--orient-offset, 0px)); } form { diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index cd67a4cc0e..0f1822a234 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -29,7 +29,9 @@ } .settings__panel--users { - max-height: 80dvh; + @media (min-width: 640px) { + max-height: 80dvh; + } @media (min-width: 960px) { max-height: calc(100dvh - 12rem); diff --git a/app/assets/stylesheets/tooltips.css b/app/assets/stylesheets/tooltips.css index f6ff54ffc3..ab142afdc8 100644 --- a/app/assets/stylesheets/tooltips.css +++ b/app/assets/stylesheets/tooltips.css @@ -20,12 +20,12 @@ &.orient-right { inset-inline: 0 auto; - translate: 0 -100%; + translate: var(--orient-offset, 0px) -100%; } &.orient-left { inset-inline: auto 0; - translate: 0 -100%; + translate: var(--orient-offset, 0px) -100%; } } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 946a0900e5..9e0a72f813 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,7 +11,7 @@ def icon_tag(name, **options) end def back_link_to(label, url, action, **options) - link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do + link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 9b9ee89b36..5f12b9259d 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,7 @@ def link_back_to_board(board) end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn", + 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 diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index a2e14af29a..2468595393 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -24,7 +24,7 @@ def webhook_action_label(action) def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), - class: [ "btn", { "btn--reversed": board.webhooks.any? } ], + class: [ "btn btn--circle-mobile", { "btn--reversed": board.webhooks.any? } ], data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index 037c5ec7b5..b52eb2ea6f 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -26,7 +26,7 @@ export default class extends Controller { this.dialogTarget.showModal() } else { this.dialogTarget.show() - orient(this.dialogTarget) + orient({ target: this.dialogTarget, anchor: this.element }) } this.loadLazyFrames() @@ -46,7 +46,7 @@ export default class extends Controller { this.dialogTarget.close() this.dialogTarget.setAttribute("aria-hidden", "true") this.dialogTarget.blur() - orient(this.dialogTarget, false) + orient({ target: this.dialogTarget, reset: true }) this.dispatch("close") } diff --git a/app/javascript/controllers/expandable_on_native_controller.js b/app/javascript/controllers/expandable_on_native_controller.js new file mode 100644 index 0000000000..a58ddfa9a1 --- /dev/null +++ b/app/javascript/controllers/expandable_on_native_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" +import { isNative } from "helpers/platform_helpers" + +export default class extends Controller { + static get shouldLoad() { + return isNative() + } + + static values = { autoExpandSelector: String } + + connect() { + if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) { + this.element.open = true + } + } +} diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index fc86b18f73..a30d5dd233 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -30,7 +30,7 @@ export default class extends Controller { reset() { this.zoomedImageTarget.src = "" - this.captionTarget.innerText = "" + this.captionTarget.innerHtml = " " this.dispatch('closed') } diff --git a/app/javascript/controllers/tooltip_controller.js b/app/javascript/controllers/tooltip_controller.js index 0b242f157e..57958cd15e 100644 --- a/app/javascript/controllers/tooltip_controller.js +++ b/app/javascript/controllers/tooltip_controller.js @@ -15,11 +15,11 @@ export default class extends Controller { } mouseEnter(event) { - orient(this.#tooltipElement) + orient({ target: this.#tooltipElement, anchor: this.element }) } mouseOut(event) { - orient(this.#tooltipElement, false) + orient({ target: this.#tooltipElement, reset: true }) } get #tooltipElement() { diff --git a/app/javascript/controllers/touch_placeholder_controller.js b/app/javascript/controllers/touch_placeholder_controller.js new file mode 100644 index 0000000000..f1798873e6 --- /dev/null +++ b/app/javascript/controllers/touch_placeholder_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" +import { isTouchDevice } from "helpers/platform_helpers" + +export default class extends Controller { + static get shouldLoad() { + return isTouchDevice() + } + + static values = { placeholder: String } + + connect() { + if (this.hasPlaceholderValue) { + this.element.placeholder = this.placeholderValue + } + } +} diff --git a/app/javascript/helpers/orientation_helpers.js b/app/javascript/helpers/orientation_helpers.js index b79d663601..f823de09ab 100644 --- a/app/javascript/helpers/orientation_helpers.js +++ b/app/javascript/helpers/orientation_helpers.js @@ -1,24 +1,40 @@ const EDGE_THRESHOLD = 16 -export function orient(el, orient = true) { - el.classList.remove("orient-left", "orient-right") +export function orient({ target, anchor = null, reset = false }) { + target.classList.remove("orient-left", "orient-right") + target.style.removeProperty("--orient-offset") - if (!orient) return + if (reset) return - const rightSpace = spaceOnRight(el) - const leftSpace = spaceOnLeft(el) + const targetGeometry = geometry(target) + const anchorGeometry = geometry(anchor) + const shouldOrientLeft = targetGeometry.spaceOnRight < EDGE_THRESHOLD && targetGeometry.spaceOnRight < targetGeometry.spaceOnLeft + const shouldOrientRight = targetGeometry.spaceOnLeft < EDGE_THRESHOLD && targetGeometry.spaceOnLeft < targetGeometry.spaceOnRight - if (rightSpace < EDGE_THRESHOLD && rightSpace < leftSpace) { - el.classList.add("orient-left") - } else if (leftSpace < EDGE_THRESHOLD && leftSpace < rightSpace) { - el.classList.add("orient-right") + if (shouldOrientLeft) { + orientLeft({ el: target, targetGeometry, anchorGeometry }) + } else if (shouldOrientRight) { + orientRight({ el: target, targetGeometry, anchorGeometry }) } } -function spaceOnLeft(el) { - return el.getBoundingClientRect().left +function orientLeft({ el, targetGeometry, anchorGeometry }) { + const offset = Math.min(0, anchorGeometry.spaceOnLeft + anchorGeometry.width - targetGeometry.width) * -1 + el.classList.add("orient-left") + el.style.setProperty("--orient-offset", `${offset}px`) } -function spaceOnRight(el) { - return window.innerWidth - el.getBoundingClientRect().right +function orientRight({ el, targetGeometry, anchorGeometry }) { + const offset = Math.max(0, anchorGeometry.spaceOnLeft + targetGeometry.width - window.innerWidth) * -1 + el.classList.add("orient-right") + el.style.setProperty("--orient-offset", `${offset}px`) +} + +function geometry(el) { + const rect = el.getBoundingClientRect() + return { + spaceOnLeft: rect.left, + spaceOnRight: window.innerWidth - rect.right, + width: rect.width + } } diff --git a/app/javascript/helpers/platform_helpers.js b/app/javascript/helpers/platform_helpers.js index 636e4b9681..b5056d5a6e 100644 --- a/app/javascript/helpers/platform_helpers.js +++ b/app/javascript/helpers/platform_helpers.js @@ -13,3 +13,7 @@ export function isAndroid() { export function isMobile() { return isIos() || isAndroid() } + +export function isNative() { + return /Hotwire Native/.test(navigator.userAgent) +} diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 928237a84c..5058d6455e 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -1,4 +1,5 @@ <% @page_title = "Create a new board" %> +<% @body_class = "compact-on-touch" %>
<%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %> diff --git a/app/views/boards/show/menu/_columns.html.erb b/app/views/boards/show/menu/_columns.html.erb index 017527a43c..b227b72efe 100644 --- a/app/views/boards/show/menu/_columns.html.erb +++ b/app/views/boards/show/menu/_columns.html.erb @@ -1,5 +1,5 @@
- diff --git a/app/views/cards/container/_gild.html.erb b/app/views/cards/container/_gild.html.erb index a40f0c0c2a..bd4b4980b7 100644 --- a/app/views/cards/container/_gild.html.erb +++ b/app/views/cards/container/_gild.html.erb @@ -1,11 +1,11 @@ <% if card.golden? %> - <%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed", + <%= 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" %> Demote to normal (shift+g) <% end %> <% else %> - <%= button_to card_goldness_path(card), class: "btn", + <%= 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" %> Promote to Golden Ticket (shift+g) diff --git a/app/views/cards/container/_image.html.erb b/app/views/cards/container/_image.html.erb index 9edda22496..e044b9b0ce 100644 --- a/app/views/cards/container/_image.html.erb +++ b/app/views/cards/container/_image.html.erb @@ -6,7 +6,7 @@ <% end %> <% elsif !card.closed? %> <%= form_with model: card, data: { controller: "form" } do |form| %> -