Merge pull request #2178 from basecamp/mobile-app/prepare-webviews

Mobile app / Prepare webviews
This commit is contained in:
Jay Ohms
2026-01-06 09:05:13 -05:00
committed by GitHub
22 changed files with 291 additions and 123 deletions
+10 -2
View File
@@ -107,13 +107,22 @@
> * {
grid-area: 1/1;
}
@media (max-width: 639px) {
--btn-size: 3em;
--icon-size: 70%;
}
}
/* Make a normal button circular on mobile */
@media (max-width: 639px) {
.btn--circle-mobile {
--btn-size: 3em;
--btn-padding: 0;
--icon-size: 70%;
aspect-ratio: 1;
padding: 0.5em;
inline-size: var(--btn-size);
kbd,
span:last-of-type {
@@ -270,4 +279,3 @@
}
}
}
+36 -7
View File
@@ -5,6 +5,7 @@
--actions-block-inset: 1.5rem;
--actions-inline-inset: 4rem;
--color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-canvas));
--half-btn-height: 1.25rem;
--padding-inline: calc(var(--block-space-double) + var(--block-space));
--padding-block: calc(var(--block-space-double) + var(--block-space-half));
@@ -14,7 +15,8 @@
grid-template-areas:
"notch-top notch-top notch-top"
"actions-left card actions-right"
"notch-bottom notch-bottom notch-bottom";
"notch-bottom notch-bottom notch-bottom"
"closure-message closure-message closure-message";
grid-template-columns: 48px minmax(0, 1120px) 48px;
inline-size: fit-content;
margin-block-start: var(--block-space);
@@ -47,6 +49,7 @@
.card__body {
@media (max-width: 639px) {
flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5);
}
}
@@ -89,15 +92,30 @@
@media (max-width: 639px) {
border: 1px solid var(--card-color);
border-radius: 0.25em;
overflow: hidden;
flex-direction: row;
gap: 0;
overflow: auto;
max-inline-size: 100%;
padding: 0;
position: relative;
white-space: nowrap;
& > form {
flex-grow: 1;
max-inline-size: 25ch;
min-block-size: 2.5em;
}
& > form + form:not(:has(.card__column-name--current)) {
box-shadow: -1px 0 0 0 var(--color-container);
}
}
}
.card__column-name {
@media (max-width: 639px) {
--btn-border-radius: 0;
justify-content: center;
}
}
@@ -127,13 +145,15 @@
}
@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";
"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));
@@ -151,7 +171,12 @@
grid-area: card;
padding: clamp(2rem, 4vw, var(--padding-block));
@media (max-width: 799px) {
@media (max-width: 639px) {
padding: clamp(0.25rem, 2vw, var(--padding-block));
padding-block-end: clamp(2.5rem, 4vw, var(--padding-block));
}
@media (min-width: 640px) and (max-width: 799px) {
padding-inline: var(--padding-inline);
}
}
@@ -263,13 +288,14 @@
}
.card-perma__notch--bottom {
--half-btn-height: 1.25rem;
grid-area: notch-bottom;
/* Overlap the card BG by half the button height */
&:has(.btn) {
translate: 0 calc(-1 * var(--half-btn-height));
&,
~ .card-perma__closure-message {
translate: 0 calc(-1 * var(--half-btn-height));
}
}
form {
@@ -314,5 +340,8 @@
.card-perma__closure-message {
color: var(--card-color);
grid-area: closure-message;
margin-block-start: 0.5ch;
padding-inline: 1ch;
}
}
+5
View File
@@ -225,6 +225,11 @@
padding-block: calc(var(--events-gap) * 3) var(--events-gap);
position: sticky;
z-index: var(--z-events-column-header);
@media (max-width: 639px) {
margin-inline: calc(var(--main-padding) * -1);
padding-inline: var(--main-padding);
}
}
.events__column-footer {
+4 -6
View File
@@ -122,17 +122,15 @@
/* Optional class to stack header actions on small screens
/* ------------------------------------------------------------------------ */
/* .header--mobile-actions-stack {
.header--mobile-actions-stack {
@media (max-width: 639px) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"menu menu"
"actions-start actions-end"
"title title";
"actions-start menu actions-end"
"title title title";
.header__title {
margin-block-start: 0.25rem;
}
}
} */
}
}
+32
View File
@@ -6,8 +6,40 @@
--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));
-webkit-tap-highlight-color: transparent;
.hide-on-native {
display: none;
}
/* Header
/* ------------------------------------------------------------------------ */
.header:is(
:not(:has(.header__title, .header__actions)),
:not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child)
) {
display: none;
}
.header__actions {
.btn--back {
display: none;
}
}
/* Card perma
/* ------------------------------------------------------------------------ */
.card-perma {
margin-block-start: 0;
}
/* Search
/* ------------------------------------------------------------------------ */
.search__title {
text-decoration: none;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
@layer components {
.pins-list {
--panel-size: 45ch;
}
}
+28 -6
View File
@@ -26,7 +26,7 @@ summary {
max-block-size: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--block-space) calc(var(--block-space-double) + var(--btn-size));
padding: var(--block-space);
}
/* Form
@@ -34,18 +34,22 @@ summary {
.search__input {
--clear-icon-size: 1em;
--focus-ring-size: 0;
--input-border-color: var(--color-ink-light);
--input-border-radius: 0;
--input-padding: 0.1em;
border-width: 0 0 1px;
max-inline-size: 50ch;
position: relative;
&::-webkit-search-cancel-button {
display: none;
}
.bar__input & {
--focus-ring-size: 0;
--input-border-color: var(--color-ink-light);
--input-border-radius: 0;
--input-padding: 0.1em;
border-width: 0 0 1px;
}
}
.search__reset {
@@ -107,4 +111,22 @@ summary {
.search__title {
text-decoration: underline;
}
/* Perma
/* ------------------------------------------------------------------------ */
.search-perma {
.search__form > label,
.search__reset {
display: none;
}
.search__input {
max-inline-size: min(80ch, 100%);
}
.search {
padding-inline: 0;
}
}
}
+66 -67
View File
@@ -156,11 +156,11 @@
--tray-item-scale: calc(1 - (var(--tray-item-index) - 1) / 30);
--tray-item-z: calc(6 - var(--tray-item-index));
font-size: 10px;
margin-block-end: var(--tray-item-margin);
position: relative;
.tray__dialog & {
font-size: 10px;
margin-block-end: var(--tray-item-margin);
transition: var(--tray-duration) var(--ease-out-overshoot-subtle);
transition-delay: var(--tray-item-delay);
transition-property: margin, opacity, scale;
@@ -285,7 +285,7 @@
/* Tray cards
/* ------------------------------------------------------------------------ */
.tray {
.tray__item {
.card {
--card-padding-block: 1.5ch;
--card-padding-inline: 1.5ch;
@@ -345,52 +345,69 @@
inset-inline-start: -100%;
}
.tray__item {
--tray-item-z: calc(10 - var(--tray-item-index));
/* Disable the expander if there aren't items to show */
.tray__dialog:not(:has(.tray__item)) ~ .tray__toggle {
opacity: 0.5;
position: relative;
[open] &[aria-selected] {
outline: 0;
z-index: calc(var(--tray-item-z) + 2);
.card__link {
border-radius: 0.25ch;
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
z-index: 1;
}
&, .tray__toggle-btn {
pointer-events: none;
}
}
/* Show 6 max items on smallest devices */
@media (max-height: 578px) {
&:nth-child(1n + 7) { display: none; }
/* Add a border on mobile */
@media (max-width: 799px) {
.tray__dialog:not([open]) ~ .tray__toggle {
border-inline-end: 1px dashed var(--color-ink-light);
translate: calc(-1 * var(--tray-margin)) 0;
}
}
}
/* 7 max */
@media (min-height: 578px) and (max-height: 656px) {
&:nth-child(1n + 8) { display: none; }
}
.tray__item--pin {
--tray-item-z: calc(10 - var(--tray-item-index));
/* 8 max */
@media (min-height: 656px) and (max-height: 734px) {
&:nth-child(1n + 9) { display: none; }
}
position: relative;
/* 9 max */
@media (min-height: 734px) and (max-height: 812px) {
&:nth-child(1n + 10) { display: none; }
}
[open] &[aria-selected] {
outline: 0;
z-index: calc(var(--tray-item-z) + 2);
/* 10 max on larger screens */
@media (min-height: 812px) {
&:nth-child(1n + 11) { display: none; }
.card__link {
border-radius: 0.25ch;
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
z-index: 1;
}
}
&:not([aria-selected]) .card__link:focus-visible,
.tray__dialog:not([open]) & .card__link:focus-visible {
--focus-ring-size: 0;
}
/* Show 6 max items on smallest devices */
@media (max-height: 578px) {
&:nth-child(1n + 7) { display: none; }
}
/* 7 max */
@media (min-height: 578px) and (max-height: 656px) {
&:nth-child(1n + 8) { display: none; }
}
/* 8 max */
@media (min-height: 656px) and (max-height: 734px) {
&:nth-child(1n + 9) { display: none; }
}
/* 9 max */
@media (min-height: 734px) and (max-height: 812px) {
&:nth-child(1n + 10) { display: none; }
}
/* 10 max on larger screens */
@media (min-height: 812px) {
&:nth-child(1n + 11) { display: none; }
}
&:not([aria-selected]) .card__link:focus-visible,
.tray__dialog:not([open]) & .card__link:focus-visible {
--focus-ring-size: 0;
}
.tray__remove-pin-btn {
@@ -399,18 +416,17 @@
background-color: var(--card-bg-color);
inset: 0 0 auto auto;
opacity: 0;
pointer-events: none;
opacity: 0.66;
position: absolute;
z-index: 1;
.tray__dialog[open] & {
opacity: 0.66;
pointer-events: unset;
&:hover {
opacity: 1;
}
&:hover {
opacity: 1;
}
.tray__dialog:not([open]) & {
opacity: 0;
pointer-events: none;
}
[aria-busy] & {
@@ -451,9 +467,10 @@
inline-size: fit-content;
margin: 0 0 0 auto;
transition: translate 150ms ease-out;
translate: -2em;
[open] & {
translate: -2em;
.tray__dialog:not([open]) & {
translate: 0;
}
}
@@ -491,24 +508,6 @@
.card__bubble {
display: none;
}
/* Disable the expander if there aren't items to show */
.tray__dialog:not(:has(.tray__item)) ~ .tray__toggle {
opacity: 0.5;
&, .tray__toggle-btn {
pointer-events: none;
}
}
/* Add a border on mobile */
@media (max-width: 799px) {
.tray__dialog:not([open]) ~ .tray__toggle {
border-inline-end: 1px dashed var(--color-ink-light);
translate: calc(-1 * var(--tray-margin)) 0;
}
}
}
::view-transition-group(tray-pins) {
+2 -2
View File
@@ -6,8 +6,8 @@ module ColumnsHelper
method: :post,
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
style: "--column-color: #{column.color}",
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" }
form_class: "flex gap-half",
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
end
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
@@ -1,5 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
import { isMobile } from "helpers/platform_helpers"
export default class extends Controller {
static targets = [ "item", "input" ]
@@ -18,6 +19,11 @@ export default class extends Controller {
onlyActOnFocusedItems: { type: Boolean, default: false }
}
// Don't load for mobile devices
static get shouldLoad() {
return !isMobile()
}
connect() {
if (this.autoSelectValue) {
this.reset()
@@ -0,0 +1,19 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "target" ]
connect() {
this.#scrollTargetIntoView()
}
#scrollTargetIntoView() {
if(this.hasTargetTarget) {
this.element.scrollTo({
top: this.targetTarget.offsetTop - this.element.offsetHeight / 2 + this.targetTarget.offsetHeight / 2,
left: this.targetTarget.offsetLeft - this.element.offsetWidth / 2 + this.targetTarget.offsetWidth / 2,
behavior: "instant"
})
}
}
}
@@ -1,10 +1,11 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventNamed } from "helpers/timing_helpers"
import { isTouchDevice } from "helpers/platform_helpers"
export default class extends Controller {
// Only load for touch devices
static get shouldLoad() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
return isTouchDevice()
}
// Use a fake input to trigger the soft keyboard on actions that load async content
@@ -0,0 +1,15 @@
export function isTouchDevice() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
export function isIos() {
return /iPhone|iPad/.test(navigator.userAgent)
}
export function isAndroid() {
return /Android/.test(navigator.userAgent)
}
export function isMobile() {
return isIos() || isAndroid()
}
+1 -1
View File
@@ -8,7 +8,7 @@
</div>
<div class="bar__input" data-bar-target="search" hidden>
<%= render "searches/form", query_terms: "" %>
<%= render "searches/form", query_terms: "", target_turbo_frame: "bar_content" %>
</div>
<%= tag.dialog id: "bar-modal", class: "bar__modal", data: {
+7 -4
View File
@@ -1,5 +1,5 @@
<%= turbo_frame_tag @card, :columns do %>
<div class="card__stages" role="radiogroup">
<div class="card__stages" role="radiogroup" data-controller="scroll-to">
<legend class="for-screen-reader">Choose a column for this card</legend>
<%= button_to "Not now", card_not_now_path(@card),
@@ -7,14 +7,16 @@
style: "--column-color: var(--color-card-complete)",
role: "radio",
aria: { checked: @card.postponed? },
form_class: "flex align-center gap-half" %>
data: { scroll_to_target: @card.postponed? ? "target" : nil },
form_class: "flex gap-half" %>
<%= button_to "Maybe?", card_triage_path(@card), method: :delete,
class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ],
style: "--column-color: var(--color-card-default)",
role: "radio",
aria: { checked: @card.awaiting_triage? },
form_class: "flex align-center gap-half" %>
data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil },
form_class: "flex gap-half" %>
<% @columns.each do |column| %>
<%= button_to_set_column @card, column %>
@@ -25,6 +27,7 @@
style: "--column-color: var(--color-card-complete)",
role: "radio",
aria: { checked: @card.closed? },
form_class: "flex align-center gap-half" %>
data: { scroll_to_target: @card.closed? ? "target" : nil },
form_class: "flex gap-half" %>
</div>
<% end %>
+15 -14
View File
@@ -1,20 +1,21 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column" id="<%= dom_id(card, :card_closure_toggle) %>">
<div class="display-contents" id="<%= dom_id(card, :card_closure_toggle) %>">
<% if card.closed? %>
<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) %>.</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 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) %>.</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>
</div>
<% else %>
<%= render "cards/container/closure_buttons", card: card %>
<div id="<%= dom_id(card, :closure_notice) %>">
<% if card.entropic? && card.open? && !card.postponed? %>
<div class="card-perma__closure-message">
Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if theres no activity.
</div>
<% end %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<%= render "cards/container/closure_buttons", card: card %>
</div>
<% if card.entropic? && card.open? && !card.postponed? %>
<div class="card-perma__closure-message" id="<%= dom_id(card, :closure_notice) %>">
Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if theres no activity.
</div>
<% end %>
<% end %>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<% @page_title = "Home" %>
<% @header_class = "header--events header--mobile-actions-stack" %>
<% @header_class = "header--events" %>
<%= render "cards/broadcasts", filter: @filter %>
+2 -2
View File
@@ -3,7 +3,7 @@
<%= render "layouts/shared/head" %>
<body 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 %>">
<header class="header <%= @header_class %>" id="header">
<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 %>
<%= yield :header %>
@@ -16,7 +16,7 @@
<%= yield %>
</main>
<footer id="footer">
<footer id="footer" class="hide-on-native">
<%= yield :footer %>
<% if Current.user && !@hide_footer_frames %>
+1 -1
View File
@@ -1,4 +1,4 @@
<nav class="nav"
<nav class="nav hide-on-native"
data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames">
<%= tag.button class:"nav__trigger input input--select center flex-inline align-center txt-normal", data: {
+14 -2
View File
@@ -1,3 +1,15 @@
<%= turbo_frame_tag "pins" do %>
<%= render partial: "my/pins/pin", collection: @pins %>
<% @page_title = "Pinned" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<div class="pins-list panel panel--wide flex center borderless unpad flex-column gap-half margin-block-start">
<%= turbo_frame_tag "pins" do %>
<%= render partial: "my/pins/pin", collection: @pins %>
<% end %>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { bar_target: "form", turbo_frame: "bar_content" } do |form| %>
<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { bar_target: "form", turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>
<%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %>
<%= text_field_tag :q, query_terms,
class: "search__input input",
+19 -6
View File
@@ -1,7 +1,20 @@
<%= turbo_frame_tag "bar_content" do %>
<% if @card %>
<%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %>
<% else %>
<%= render "results", page: @page %>
<% end %>
<% @page_title = params.has_key?(:q) ? "Search results for \"#{params[:q]}\"" : "Search" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<div class="search-perma margin-block-start">
<%= render "form", query_terms: params[:q] %>
<%= turbo_frame_tag "bar_content" do %>
<% if @card %>
<%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %>
<% else %>
<%= render "results", page: @page %>
<% end %>
<% end %>
</div>