feat: visual polish, focus-point cropping, blurhash placeholders (Release 8)

Card styling: softer 8px radius, subtle box-shadow elevation, hover enhancement.
Action buttons: borderless with color-coded hover states via color-mix().
Typography: tighter line-height (4/3), larger avatars (44px), gallery images (220px).
Focus-point cropping: convert Mastodon focus.x/y to CSS object-position.
Blurhash placeholders: decode DC component to background-color on images.

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
This commit is contained in:
Ricardo
2026-03-03 19:26:38 +01:00
parent b9fc98f40c
commit 9332421890
7 changed files with 159 additions and 37 deletions
+66
View File
@@ -0,0 +1,66 @@
/**
* Blurhash placeholder backgrounds for gallery images.
*
* Extracts the average (DC) color from a blurhash string and applies it
* as a background-color on images with a data-blurhash attribute.
* This provides a meaningful colored placeholder while images load.
*
* The DC component is encoded in the first 4 characters of the blurhash
* after the size byte, as a base-83 integer representing an sRGB color.
*/
const BASE83_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
function decode83(str) {
let value = 0;
for (const c of str) {
const digit = BASE83_CHARS.indexOf(c);
if (digit === -1) return 0;
value = value * 83 + digit;
}
return value;
}
function decodeDC(value) {
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255,
};
}
function blurhashToColor(hash) {
if (!hash || hash.length < 6) return null;
const dcValue = decode83(hash.slice(1, 5));
const { r, g, b } = decodeDC(dcValue);
return `rgb(${r},${g},${b})`;
}
document.addEventListener("DOMContentLoaded", () => {
for (const img of document.querySelectorAll("img[data-blurhash]")) {
const color = blurhashToColor(img.dataset.blurhash);
if (color) {
img.style.backgroundColor = color;
}
}
// Handle dynamically loaded images (infinite scroll)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
const imgs = node.querySelectorAll
? node.querySelectorAll("img[data-blurhash]")
: [];
for (const img of imgs) {
const color = blurhashToColor(img.dataset.blurhash);
if (color) {
img.style.backgroundColor = color;
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
+3 -3
View File
@@ -15,16 +15,16 @@
.ap-link-preview {
display: flex;
overflow: hidden;
border-radius: var(--border-radius-small);
border-radius: 8px;
border: 1px solid var(--color-neutral-lighter);
background-color: var(--color-offset);
text-decoration: none;
color: inherit;
transition: border-color 0.2s ease;
transition: border-color 0.15s ease;
}
.ap-link-preview:hover {
border-color: var(--color-primary);
border-color: var(--color-outline-variant);
}
/* Text content area (left side) */
+71 -31
View File
@@ -150,10 +150,11 @@
.ap-card {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-left: var(--border-width-thickest) solid var(--color-outline);
border-radius: var(--border-radius-small);
border-left: 3px solid var(--color-outline);
border-radius: 8px;
overflow: hidden;
padding: var(--space-m);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
@@ -162,6 +163,7 @@
.ap-card:hover {
border-color: var(--color-outline-variant);
border-left-color: var(--color-outline-variant);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* ==========================================================================
@@ -263,9 +265,9 @@
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
flex-shrink: 0;
height: 40px;
height: 44px;
object-fit: cover;
width: 40px;
width: 44px;
}
.ap-card__avatar--default {
@@ -282,10 +284,12 @@
display: flex;
flex-direction: column;
flex: 1;
gap: 1px;
min-width: 0;
}
.ap-card__author-name {
font-size: 0.95em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
@@ -327,7 +331,7 @@
.ap-card__timestamp {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
font-size: var(--font-size-s);
}
.ap-card__edited {
@@ -374,7 +378,7 @@
.ap-card__content {
color: var(--color-on-background);
line-height: var(--line-height-prose);
line-height: calc(4 / 3 * 1em);
margin-bottom: var(--space-s);
overflow-wrap: break-word;
word-break: break-word;
@@ -504,7 +508,7 @@
========================================================================== */
.ap-card__gallery {
border-radius: var(--border-radius-small);
border-radius: 6px;
display: grid;
gap: 2px;
margin-bottom: var(--space-s);
@@ -524,9 +528,14 @@
.ap-card__gallery img {
background: var(--color-offset-variant);
display: block;
height: 200px;
height: 220px;
object-fit: cover;
width: 100%;
transition: filter 0.2s ease;
}
.ap-card__gallery-link:hover img {
filter: brightness(0.92);
}
.ap-card__gallery-link--more::after {
@@ -557,7 +566,7 @@
.ap-card__gallery--1 img {
height: auto;
max-height: 400px;
max-height: 500px;
}
/* 2 photos — side by side */
@@ -761,60 +770,86 @@
border-top: var(--border-width-thin) solid var(--color-outline);
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
gap: 2px;
padding-top: var(--space-s);
}
.ap-card__action {
align-items: center;
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
border: 0;
border-radius: 6px;
color: var(--color-on-offset);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-s);
gap: var(--space-xs);
padding: var(--space-xs) var(--space-s);
gap: 0.3em;
min-height: 36px;
padding: 0.25em 0.6em;
text-decoration: none;
transition: all 0.2s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.ap-card__action:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
color: var(--color-on-background);
}
/* Active interaction states — using Indiekit's color palette */
/* Color-coded hover states per action type */
.ap-card__action--reply:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action--boost:hover {
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--like:hover {
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--link:hover {
background: var(--color-offset-variant);
color: var(--color-on-background);
}
.ap-card__action--save:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
/* Active interaction states */
.ap-card__action--like.ap-card__action--active {
background: var(--color-red90);
border-color: var(--color-red45);
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--boost.ap-card__action--active {
background: var(--color-green90);
border-color: var(--color-green50);
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--save.ap-card__action--active {
background: #4a9eff22;
border-color: #4a9eff;
color: #4a9eff;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action:disabled {
cursor: wait;
opacity: 0.6;
opacity: 0.5;
}
/* Interaction counts */
.ap-card__count {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin-left: 0.25rem;
color: inherit;
opacity: 0.7;
margin-left: 0.1em;
font-variant-numeric: tabular-nums;
}
@@ -2536,9 +2571,14 @@
.ap-quote-embed {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
border-radius: 8px;
margin-top: var(--space-s);
overflow: hidden;
transition: border-color 0.15s ease;
}
.ap-quote-embed:hover {
border-color: var(--color-outline-variant);
}
.ap-quote-embed--pending {
@@ -2553,7 +2593,7 @@
}
.ap-quote-embed__link:hover {
background: var(--color-offset);
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
}
.ap-quote-embed__author {
@@ -2618,8 +2658,8 @@
.ap-quote-embed__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: 1.5;
max-height: calc(1.5em * 6);
line-height: calc(4 / 3 * 1em);
max-height: calc(1.333em * 6);
overflow: hidden;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.6.0",
"version": "2.6.1",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
+2
View File
@@ -19,6 +19,8 @@
{# AP link interception for internal navigation #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.js"></script>
{# Blurhash placeholder backgrounds for gallery images #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-blurhash.js"></script>
{% if readerParent %}
<nav class="ap-breadcrumb" aria-label="Breadcrumb">
+9 -1
View File
@@ -10,10 +10,18 @@
{# Support both old string format and new object format #}
{% set photoSrc = photo.url if photo.url else photo %}
{% set photoAlt = photo.alt if photo.alt else "" %}
{% set photoBlurhash = photo.blurhash if photo.blurhash else "" %}
{# Focus-point cropping: convert -1..1 range to CSS object-position percentages #}
{% set focusStyle = "" %}
{% if photo.focus and photo.focus.x != null and photo.focus.y != null %}
{% set fpX = ((photo.focus.x + 1) / 2 * 100) %}
{% set fpY = ((1 - (photo.focus.y + 1) / 2) * 100) %}
{% set focusStyle = "object-position:" + fpX + "% " + fpY + "%;" %}
{% endif %}
{% if loop.index0 < 4 %}
<div class="ap-card__gallery-item" x-data="{ showAlt: false }">
<button type="button" @click="idx = {{ loop.index0 }}; lightbox = true" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
<img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy">
<img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy"{% if focusStyle %} style="{{ focusStyle }}"{% endif %}{% if photoBlurhash %} data-blurhash="{{ photoBlurhash }}"{% endif %}>
{% if loop.index0 == 3 and extraCount > 0 %}
<span class="ap-card__gallery-more">+{{ extraCount }}</span>
{% endif %}
+7 -1
View File
@@ -26,8 +26,14 @@
{% endif %}
{% if item.quote.photo and item.quote.photo.length > 0 %}
{% set qPhoto = item.quote.photo[0] %}
{% set qFocusStyle = "" %}
{% if qPhoto.focus and qPhoto.focus.x != null and qPhoto.focus.y != null %}
{% set qFpX = ((qPhoto.focus.x + 1) / 2 * 100) %}
{% set qFpY = ((1 - (qPhoto.focus.y + 1) / 2) * 100) %}
{% set qFocusStyle = "object-position:" + qFpX + "% " + qFpY + "%;" %}
{% endif %}
<div class="ap-quote-embed__media">
<img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo">
<img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo"{% if qFocusStyle %} style="{{ qFocusStyle }}"{% endif %}>
</div>
{% endif %}
</a>