Compare commits

...
4 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 bd73ecbe12 Release v0.7.2
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:09:52 +01:00
tbenandClaude Opus 5.5 50dd6976ee Mark and round the uploaded icon's favicon, enlarge the square icon's mark
An uploaded image's favicon now carries the stone mark like the PWA
icons and is cut to a circle. The mark is larger on the favicon and on
the regular (rounded-square) icons, and the settings preview matches.
A render revision in the set's version gives re-saved icons a new URL
past the immutable cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:09:21 +01:00
tbenandClaude Opus 5.5 8c4658f6e3 Release v0.7.1
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:55:23 +01:00
tbenandClaude Opus 5.5 d3ce787087 Show every bot of a chatroom in its rail icon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:54:55 +01:00
7 changed files with 126 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.7.0",
"version": "0.7.2",
"module": "index.ts",
"type": "module",
"private": true,
@@ -0,0 +1,48 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { ChatroomBot } from "@/src/types";
import ChatroomStone from "./chatroom-stone";
type Props = {
bots: ChatroomBot[];
};
/**
* The square the cluster fills (`w-7 h-7`), the size of a rail avatar.
*/
const ClusterSize = 28;
/**
* A chatroom's bots packed into one avatar-sized square: the asking bot
* on top, the two below it in a triangle (four make a square, the rest
* are left out). Stones stay small and apart: never overlapping, in a
* badge or outlined. One bot fills the square alone.
*/
export default function ChatroomStoneCluster({ bots }: Props) {
const shown = bots.slice(0, 4);
const stone = Math.floor((ClusterSize - 2) / 2);
if (shown.length <= 1) {
return shown[0] ? <ChatroomStone bot={shown[0]} size={ClusterSize} /> : null;
}
const top = shown.length == 3 ? shown.slice(0, 1) : shown.slice(0, 2);
const bottom = shown.length == 2 ? [] : shown.slice(top.length);
return (
<Stack className="shrink-0 gap-0.5! items-center justify-center w-7 h-7">
<Row className="gap-0.5! flex-nowrap justify-center">
{top.map((bot) => (
<ChatroomStone key={bot.id} bot={bot} size={stone} />
))}
</Row>
{bottom.length ? (
<Row className="gap-0.5! flex-nowrap justify-center">
{bottom.map((bot) => (
<ChatroomStone key={bot.id} bot={bot} size={stone} />
))}
</Row>
) : null}
</Stack>
);
}
+21 -1
View File
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
export const PwaCornerMarkInset = 0.2;
export const PwaCornerMarkScale = 0.22;
/**
* The corner mark on the regular (rounded-square) icons, which aren't cut
* to a circle: larger and nearer the corner, clear of its rounding.
*/
export const PwaSquareCornerMarkInset = 0.16;
export const PwaSquareCornerMarkScale = 0.3;
/**
* The corner mark on a round favicon: larger, to stay readable at 32px,
* and still inside the circle.
*/
export const PwaFaviconCornerMarkInset = 0.15;
export const PwaFaviconCornerMarkScale = 0.32;
/**
* Uploads must be at least this many pixels on their shorter side, and are
* kept as a square of the source size.
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
export const PwaInstallIconMinSide = 512;
export const PwaInstallIconSourceSide = 1024;
/**
* Bump when `renderInstallIcons` draws differently: it's part of a set's
* version, so a re-saved icon gets a new URL past the `immutable` cache.
*/
export const PwaInstallIconRenderRevision = 4;
export type PwaInstallIconFile = {
name: string;
size: number;
/**
* `plain` is the image without the mark (the settings preview);
* `favicon` never gets the mark (unreadable at 32px).
* `favicon` is cut to a circle, the mark kept inside it.
*/
kind: "any" | "maskable" | "favicon" | "plain";
};
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
PwaInstallIconRenderRevision,
PwaInstallIconSettingKey,
type PwaInstallIcon,
type PwaInstallIconMode,
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
const version = is_stock
? null
: createHash("sha256")
.update(JSON.stringify({ mode, stone, mark }))
.update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
.update(source || "")
.digest("hex")
.slice(0, 12);
@@ -1,10 +1,14 @@
import sharp from "sharp";
import sharp, { type OverlayOptions } from "sharp";
import {
PwaAppIconMarkScale,
PwaCornerMarkInset,
PwaCornerMarkScale,
PwaFaviconCornerMarkInset,
PwaFaviconCornerMarkScale,
PwaInstallIconFiles,
PwaMaskableMarkScale,
PwaSquareCornerMarkInset,
PwaSquareCornerMarkScale,
type PwaInstallIcon,
type PwaInstallIconFile,
} from "@/src/data/pwa-data";
@@ -26,7 +30,8 @@ type Params = {
/**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
* A stone icon is the stone on paper, like the stock one. An image fills
* the icon, with the stone in the top-left corner when `mark` is on.
* the icon, with the stone in the top-left corner when `mark` is on; its
* favicon is cut to a circle, the mark kept inside it.
*/
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {};
@@ -79,19 +84,41 @@ async function renderImageFile({
.resize(file.size, file.size, { fit: "cover" })
.flatten({ background });
if (!mark || file.kind == "favicon" || file.kind == "plain") {
return base.png().toBuffer();
}
const layers: OverlayOptions[] = [];
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale));
const inset = Math.round(file.size * PwaCornerMarkInset);
if (mark && file.kind != "plain") {
const { scale, inset_scale } = markPlacement({ kind: file.kind });
const mark_size = Math.max(1, Math.round(file.size * scale));
const inset = Math.round(file.size * inset_scale);
const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
return base
.composite([{ input: stone, left: inset, top: inset }])
.png()
.toBuffer();
layers.push({ input: stone, left: inset, top: inset });
}
if (file.kind == "favicon") {
const half = file.size / 2;
const circle = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
);
layers.push({ input: circle, blend: "dest-in" });
}
// Layers apply in order, so the circle cuts the mark too.
return base.ensureAlpha().composite(layers).png().toBuffer();
}
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
if (kind == "favicon") {
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
}
if (kind == "any") {
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
}
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
}
@@ -2,7 +2,7 @@ import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import ChatroomStone from "@/src/components/general/chatroom-stone";
import ChatroomStoneCluster from "@/src/components/general/chatroom-stone-cluster";
import type { ChatroomSummary } from "@/src/types";
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
@@ -13,12 +13,11 @@ type Props = {
};
/**
* A chatroom in the rail, the same 48px row as a bot: the asking bot's
* stone, the request and where it stands. Unread is said in words.
* A chatroom in the rail, the same 48px row as a bot: its bots' stones
* in one avatar-sized cluster, the request and where it stands. Unread
* is said in words.
*/
export default function RailChatroomRow({ chatroom, href, selected }: Props) {
const requester = chatroom.bots[0];
return (
<Link
href={href}
@@ -32,7 +31,7 @@ export default function RailChatroomRow({ chatroom, href, selected }: Props) {
)}
>
<Span className="mx-auto @rail:mx-0 inline-flex shrink-0">
{requester ? <ChatroomStone bot={requester} size={28} /> : null}
<ChatroomStoneCluster bots={chatroom.bots} />
</Span>
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
@@ -20,7 +20,8 @@ type Props = {
/**
* The app icon as a launcher would show it, laid out like the generated
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
* or the image with the stone 20% in from the top-left at 22% of the side.
* or the image with the stone in the top-left: 20% in at 22% of the side
* (maskable, round), 16% in at 30% (rounded square).
*/
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
const stone_src = `/brand/icons/${stone}.svg`;
@@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src,
<Img
src={stone_src}
alt=""
className="absolute left-[20%] top-[20%] size-[22%]"
className={twMerge(
"absolute",
shape == "circle"
? "left-[20%] top-[20%] size-[22%]"
: "left-[16%] top-[16%] size-[30%]",
)}
/>
) : null}
</>