Files
fizzy/app/assets/stylesheets/card-columns.css
T
Mike Dalessio cb61b36715 Allow boosts on cards (#2411)
* Add reactions association to Card model

Adds `has_many :reactions` to Card, matching the implementation in Comment.
This allows cards to be reacted to directly with emoji reactions.

The association:
- Orders reactions chronologically
- Uses polymorphic `:reactable` interface
- Deletes reactions when card is destroyed

Includes test coverage for the new association.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add reactions UI to card detail view

- Create Cards::ReactionsController with turbo stream responses
- Add card reactions views (reactions list, new form, menu partial)
- Position reaction button flush-right when no reactions exist
- Show reactions on bottom-left when present, with button inline
- Handle narrow viewports by flowing button below meta section
- Add controller tests for card reactions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add boost count to card preview

Display reaction count alongside comment count on card tiles in board
columns. Introduces a .card__counts wrapper to group both counts with
proper positioning on all viewport sizes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Consolidate reaction views for cards and comments

Extract shared views to app/views/reactions/ using polymorphic routing.
Both card and comment reactions now render the same partials, with a
helper to compute the correct path prefix for nested routes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix emoji picker width in card reactions

Override .card .popup { inline-size: 260px } for the reaction popup
so the emoji grid displays without horizontal scrollbar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Hide boost count where comment count is hidden

Add .card__boosts alongside .card__comments in:
- Tray view (display: none)
- Cards with background images (opacity toggle on hover)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix reactions button overlap with zoom button in card footer

When a card has a background image, the footer contains both a reactions
button and a zoom button. Previously they would overlap because the
reactions button was absolutely positioned to the bottom-right.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Tighten up previews

* Move card reactions from meta partial to container

Fixes two issues:

1. Reactions were duplicated each time a card was assigned because the
   turbo stream replaced the meta div with the full perma/meta partial,
   which included reactions outside the replaced element.

2. Draft cards incorrectly showed the boost button because the meta
   partial was shared between published and draft containers.

Moving reactions to _container.html.erb (published cards only) fixes
both issues and keeps the meta partial focused on meta content.

Fixes https://app.fizzy.do/5986089/cards/3837
Fixes https://app.fizzy.do/5986089/cards/3835

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* WIP adjust perma page

* Position and style the footer

* Render the stamp in the card body instead of the footer

* Fix undefined local variable in public cards view

Changed `card` to `@card` when rendering the stamp partial.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Preload card reactions to avoid N+1 queries

Include reactions and their reacters in the preloaded scope, matching
what we already do for comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add model tests for card and comment reaction cleanup

Test that:
- Creating a card reaction touches the card's last_active_at
- Reactions are deleted when their parent comment is destroyed
- Reactions are deleted when their parent card is destroyed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Minor improvements to reactions code

- Use size instead of count in boosts partial to avoid extra SQL query
  on already-loaded collection
- Add else clause to reaction_path_prefix_for to raise ArgumentError
  for unknown reactable types instead of silently returning nil

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Bump specificity of reaction popup in the card perma

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy Smith <andy@37signals.com>
2026-01-23 13:25:55 -05:00

889 lines
20 KiB
CSS

@layer components {
/* Layout adjustments for contained scrolling
/* ------------------------------------------------------------------------ */
/* Scroll columns individually on mobile */
@media (max-width: 639px) {
body.contained-scrolling {
block-size: 100dvh;
grid-template-rows: 1fr var(--footer-height);
#global-container {
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
#main {
display: grid;
grid-template-rows: auto auto 1fr;
overflow: auto;
padding: 0;
}
/* Adapt the grid to public views (no filters or watchers sections) */
&.public #main {
grid-template-rows: 1fr;
}
}
}
/* Column container
/* ------------------------------------------------------------------------ */
#main:has(.card-columns) {
--main-padding: 0;
}
.card-columns {
--bubble-size: 3.5rem;
--cards-gap: min(1.2cqi, 1.7rem);
--column-gap: 8px;
--column-padding: calc(var(--column-gap) * 2);
--column-transition-duration: 300ms;
--column-width-collapsed: 40px;
--column-width-expanded: 450px;
--progress-increment: var(--progress-max-height) / var(--progress-max-cards);
--progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-height: 50dvh;
container-type: inline-size;
display: grid;
gap: var(--column-gap);
grid-template-columns: 1fr auto 1fr;
inline-size: 100%;
margin-inline: auto;
max-inline-size: var(--main-width);
outline: none;
overflow-x: auto;
overflow-y: hidden;
position: relative;
/* When it has something expanded */
&:has(.card-columns__left .is-expanded, .card-columns__right .is-expanded) {
grid-template-columns: auto auto auto;
@media (min-width: 640px) {
grid-template-columns: auto var(--column-width-expanded) auto;
}
}
&:has(.cards) {
block-size: 100%;
min-block-size: 20lh;
}
@media (max-width: 639px) {
--column-width-expanded: calc(100vw - var(--column-gap) * 4);
scroll-snap-type: inline mandatory;
&:not(:has(.is-expanded)) {
grid-template-columns: auto var(--column-width-collapsed) auto;
}
}
@media (min-width: 640px) {
padding-block-end: var(--column-width-collapsed);
}
}
.card-columns__left,
.card-columns__right {
align-items: stretch;
display: flex;
gap: var(--column-gap);
position: relative;
@media (max-width: 639px) {
min-block-size: 0;
}
}
.card-columns__left {
justify-content: end;
margin-inline-start: auto;
padding-inline-start: var(--column-gap);
@media (max-width: 639px) {
padding-inline-start: calc(var(--column-gap) * 2);
}
}
.card-columns__right {
justify-content: start;
padding-inline-end: var(--column-gap);
margin-inline-end: auto;
}
/* Column
/* ------------------------------------------------------------------------ */
.cards {
--column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas));
inline-size: var(--column-width-expanded);
outline: none;
position: relative;
scroll-snap-align: center;
&.is-expanded {
@media (max-width: 639px) {
overflow: hidden;
}
}
&.is-collapsed {
inline-size: var(--column-width-collapsed);
.pagination-link.pagination-link--active-when-observed,
.card {
display: none;
}
}
&.drag-and-drop__hover-container {
--dnd-bg-color: transparent;
--dnd-border-color: transparent;
&.is-off-screen {
&:after {
content: attr(data-column-name);
font-size: var(--text-x-small);
font-weight: 500;
line-height: var(--column-width-collapsed);
padding-inline: 1ch;
position: fixed;
text-transform: uppercase;
top: 0;
translate: -50%;
}
&.is-collapsed {
&:after {
writing-mode: vertical-rl;
}
}
&:not(.is-collapsed) {
&:after {
background-color: var(--column-color);
inline-size: calc(var(--column-width-expanded) - 4px); /* make room for the dnd border */
}
}
}
}
@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;
}
}
}
.cards__transition-container {
block-size: 100%;
border-radius: calc(var(--column-width-collapsed) / 2);
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle);
@media (min-width: 640px) {
.is-expanded & {
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
}
.is-collapsed & {
translate: 0 var(--column-width-collapsed);
}
}
.drag-and-drop__hover-container & {
--dnd-bg-color: var(--column-color);
--dnd-border-color: var(--card-color);
background-color: var(--dnd-bg-color);
outline: 2px dashed var(--dnd-border-color);
outline-offset: -2px;
transition: background-color 200ms;
z-index: 1;
}
.no-transitions & {
transition: none;
}
/* Use flex so the __list container can take up the remaining space for scrolling */
@media (max-width: 639px) {
.is-expanded & {
display: flex;
flex-direction: column;
}
}
}
/* The wrapper around the cards used to clip overflow while transitioning.
* Also, don't resize cards while transitioning to avoid reflow. */
.cards__list {
display: flex;
flex-direction: column;
gap: var(--cards-gap);
overflow-x: hidden;
overflow-y: auto;
.is-expanded & {
padding: var(--column-padding);
/* Use the rest of the column height for scrolling */
@media (max-width: 639px) {
flex: 1;
padding-inline: calc(var(--column-padding) / 4);
}
}
[aria-selected] & .card[aria-selected] {
outline: var(--focus-ring-size) solid var(--color-selected-dark);
outline-offset: var(--focus-ring-offset);
html[data-theme="dark"] & {
outline-color: oklch(var(--lch-blue-medium));
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
outline-color: oklch(var(--lch-blue-medium));
}
}
}
&:has(.card) {
.blank-slate {
display: none;
}
}
/* Use the default blank-slate on small viewports since drag-and-drop isn't available */
[data-controller~="drag-and-drop"] & {
@media (max-width: 639px) {
.blank-slate--drag {
display: none;
}
}
@media (min-width: 640px) {
.blank-slate--default {
display: none;
}
}
}
}
.cards__new-column {
position: relative;
@media (max-width: 639px) {
inset-inline-end: 0;
position: absolute;
translate: 100%;
}
@media (min-width: 640px) {
margin-block-start: var(--column-width-collapsed);
}
}
/* Cards grid; used when filtering
/* -------------------------------------------------------------------------- */
.cards--grid {
--cards-gap: 1rem;
--card-grid-columns: 1;
container-type: inline-size;
inline-size: 100%;
margin-inline: auto;
max-inline-size: var(--main-width);
@media (min-width: 640px) {
--card-grid-columns: 2;
}
@media (min-width: 960px) {
--card-grid-columns: 3;
}
.cards__list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--cards-gap);
justify-content: center;
padding: 1ch;
}
.card {
inline-size: calc((100% - var(--cards-gap) * (var(--card-grid-columns) - 1) ) / var(--card-grid-columns));
}
.card__header .card__column-name--current {
--btn-padding: 0.1em 0.5em;
background: none;
border: 1px solid currentColor;
color: var(--card-color);
display: inline-flex;
flex: 0 1 auto;
inline-size: fit-content;
margin: 0 0 0 auto;
}
.blank-slate--drag {
display: none;
}
}
/* Column Elements
/* ------------------------------------------------------------------------ */
.cards__header {
.cards.is-collapsed & {
block-size: 100%;
}
.cards.is-expanded & {
display: grid;
grid-template-areas: "menu expander maximize";
grid-template-columns: var(--column-width-collapsed) 1fr var(--column-width-collapsed);
padding-inline: var(--column-padding);
}
}
.cards__menu .btn--circle,
.cards__maximize-button {
--btn-background: transparent;
block-size: var(--column-width-collapsed);
inline-size: var(--column-width-collapsed);
opacity: 0;
outline-offset: -2px;
.cards:hover &,
&:focus-visible {
opacity: 1;
}
.cards.is-collapsed & {
display: none;
}
}
.cards__menu {
position: relative;
z-index: var(--z-popup);
}
.cards__maximize-button {
grid-area: maximize;
}
.cards__expander {
--gradient-direction: to bottom;
align-items: center;
border-radius: 99rem;
cursor: pointer;
display: flex;
flex-direction: row-reverse;
font-size: var(--text-x-small);
font-weight: 600;
gap: 0.5ch;
grid-area: expander;
justify-content: center;
outline: none;
outline-offset: -2px;
position: relative;
text-transform: uppercase;
&[disabled] {
opacity: 1;
}
@media (any-hover: hover) {
.is-collapsed:hover {
filter: brightness(0.9);
}
}
/* Progress */
&:after {
background: linear-gradient(var(--gradient-direction), var(--card-color), var(--column-color) 80%);
block-size: var(--column-width-collapsed);
border-radius: 99rem;
content: "";
inset: 0 0 auto;
margin-inline: auto;
max-block-size: var(--progress-max-height);
min-block-size: var(--column-width-collapsed);
opacity: 0;
position: absolute;
transition:
block-size 500ms var(--ease-out-overshoot),
inline-size var(--column-transition-duration) ease-out,
opacity var(--column-transition-duration) ease-out;
z-index: -1;
}
.no-transitions &:after {
transition: none;
}
.cards.is-collapsed & {
block-size: 100%;
flex-direction: column;
inline-size: var(--column-width-collapsed);
justify-content: start;
letter-spacing: 0.05em;
/* Guitar string */
&:before {
background-color: var(--column-color);
block-size: 100%;
content: "";
inline-size: 1px;
inset-block: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)) 0;
position: absolute;
z-index: -2;
}
&:after {
block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment));
max-block-size: none;
opacity: 1;
inline-size: var(--column-width-collapsed);
}
}
.cards.is-expanded & {
inline-size: 100%;
justify-content: center;
}
}
.cards__expander-count {
line-height: var(--column-width-collapsed);
inline-size: var(--column-width-collapsed);
.cards.is-expanded & {
display: none;
}
}
.cards__expander-title {
font-weight: inherit;
font-size: inherit;
line-height: var(--column-width-collapsed);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.cards.is-collapsed & {
max-inline-size: 50vh;
writing-mode: vertical-rl;
}
.cards.is-expanded & {
align-items: center;
display: flex;
gap: 0.25ch;
max-inline-size: calc(100% - var(--column-width-collapsed) * 2);
}
.icon--collapse {
--icon-size: 1.15em;
opacity: 0.66;
transition: 150ms ease-out;
transition-property: opacity, scale;
@media (min-width: 640px) {
opacity: 0;
scale: 1.5;
}
.cards.is-collapsed & {
display: none;
}
.cards.is-expanded .cards__expander:hover & {
opacity: 0.66;
scale: 1;
}
}
}
/* Override card styles within columns
/* Adding .board-tools here since it sits outside the cards container on mobile */
/* ------------------------------------------------------------------------ */
.cards .card,
.board-tools {
--block-space: 1em;
--block-space-half: 0.5em;
--card-padding-inline: 1em;
--text-xx-large: 1.6em;
--text-x-small: 1em;
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
.card__counts {
--gap: 0.5ch;
align-items: flex-end;
display: flex;
flex-shrink: 0;
gap: calc(2 * var(--gap));
margin-inline: auto calc(var(--card-padding-inline) * -0.5);
padding-inline-start: var(--gap);
}
.card__boosts,
.card__comments {
--icon-size: 1.6em;
align-items: center;
display: flex;
flex-shrink: 0;
font-weight: 600;
gap: var(--gap);
img {
block-size: var(--icon-size);
inline-size: var(--icon-size);
}
.icon--comment {
color: var(--card-color);
}
}
.card__steps {
--column-gap: 0.8ch;
display: flex;
}
.card__tags {
gap: calc(var(--card-header-space) / 2);
}
.card__title {
pointer-events: none;
}
.card__link {
z-index: 1;
}
.card__stages,
.card__hide-on-index {
display: none;
}
.card__body {
padding-block: calc(var(--card-padding-block) * 0.75) var(--card-padding-block);
}
.card__meta {
font-weight: 600;
strong,
.local-time-value {
font-weight: inherit;
}
@media (max-width: 639px) {
inline-size: auto;
}
}
&:has(.card__background img:not([src=""])) {
.card__content,
.card__meta,
.card__boosts,
.card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
@media (any-hover: hover) {
&:hover {
.card__content,
.card__footer,
.card__boosts,
.card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 1;
}
.card__background img {
filter: blur(3px) brightness(1.2);
opacity: 0.2;
}
}
}
}
.bubble {
inset-inline-start: 100%;
translate: -90% -40%;
}
}
/* Considering
/* ------------------------------------------------------------------------ */
.cards--maybe {
--card-color: oklch(var(--lch-blue-medium));
position: relative;
.card {
--avatar-size: 2.75em;
--text-small: 1.1em;
background-color: var(--color-canvas);
line-height: 1.2;
z-index: 2;
@media (min-width: 640px) {
--text-xx-large: 1.6em;
}
}
.card__board {
background-color: transparent;
color: var(--card-content-color);
}
.card__header {
color: var(--color-ink);
padding-block-start: calc(var(--card-padding-block) / 2);
}
.card__tags {
color: inherit;
}
.card__body {
padding-block: 0 var(--card-padding-block);
}
.card__people-label {
display: none;
}
.card__title {
min-block-size: 0;
}
}
/* Board tools
/* -------------------------------------------------------------------------- */
.board-tools.card {
--border-color: var(--color-selected-dark);
--border-size: 1px;
--card-padding-block: var(--block-space);
border: 1px solid var(--border-color);
inline-size: auto;
text-align: center;
@media (max-width: 639px) {
/* On mobile, hide the tool card inside the Maybe column */
.cards & {
display: none;
}
#cards_container > & {
margin: 0 3ch 1ch;
}
}
@media (min-width: 640px) {
/* On desktop, hide the tool card above the columns */
#cards_container > & {
display: none;
}
}
@media (min-width: 800px) {
margin: var(--column-padding) var(--column-padding) 0;
}
&:has(dialog[open]) {
z-index: 5;
}
.divider {
--divider-color: oklch(var(--lch-blue-light));
}
.btn--link {
font-size: 1.2em;
}
.btn:not(.btn--link, .btn--circle) {
border: 0;
color: var(--color-link);
}
footer {
font-size: var(--text-x-small);
margin-block: 1ch calc(var(--block-space-half) * -1);
}
.overflow-count {
font-size: 1.2em;
font-weight: 500;
padding: 0.5em 0.3em;
}
}
.board-tools__watching {
--btn-size: 32px;
--gap: 0.5ch;
display: flex;
gap: var(--gap);
inline-size: 100%;
margin-block: var(--block-space-half);
place-content: center;
position: relative;
}
.board-tools__watching-dialog {
--panel-padding: 2ch;
--panel-size: 100%;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
inset-block-start: 0;
justify-content: center;
position: absolute;
z-index: 1;
}
/* On Deck (Not Now)
/* ------------------------------------------------------------------------ */
.cards--closed,
.cards--on-deck {
--card-color: var(--color-ink-light) !important;
.card,
.blank-slate {
--card-color: var(--color-card-complete) !important;
}
.bubble {
display: none !important;
}
}
/* Doing
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
.cards--maybe:has(.bubble:not([hidden])) .cards__expander-title,
.cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container,
.cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-opacity: 75%;
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
&:before {
background: radial-gradient(
color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100%
);
block-size: 1em;
border-radius: var(--bubble-shape);
content: "";
inline-size: 1em;
inset: 0 0 auto auto;
position: absolute;
translate: 20% -20%;
z-index: 1;
}
/* Maybe column: position bubble relative to the title, not the container */
.cards--maybe.is-expanded & {
overflow: visible;
position: relative;
&:before {
inset-block-start: 50%;
inset-inline-start: 0;
translate: -125% -75%;
z-index: -1;
}
}
@media (max-width: 639px) {
&.cards__expander-title:before {
display: none;
}
}
html[data-theme="dark"] & {
--bubble-opacity: 100%;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--bubble-opacity: 100%;
}
}
}
/* Card column indicators
/* -------------------------------------------------------------------------- */
.card__column-name {
--btn-background: transparent;
--btn-padding: 0.2em 0.5em;
--btn-border-size: 0;
--btn-border-radius: 0.2em;
color: inherit;
inline-size: 100%;
justify-content: flex-start;
text-transform: uppercase;
@media (hover: hover) {
&:not(.card__column-name--current):hover {
--btn-background: color(from var(--column-color) srgb r g b / 0.15);
color: var(--column-color);
}
}
}
.card__column-name--current {
--btn-background: var(--card-color);
color: var(--color-ink-inverted);
opacity: 1 !important;
@media (hover: hover) {
&:hover {
--btn-background: var(--card-color);
}
}
}
}