Compare commits

..
4 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 74da091dd4 Release v0.7.3
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:32:09 +01:00
tbenandClaude Opus 5.5 a33593460b Delete a task from its list row, keep the status chip unfilled in light mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
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
9 changed files with 150 additions and 28 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.7.1",
"version": "0.7.3",
"module": "index.ts",
"type": "module",
"private": true,
+15 -1
View File
@@ -12,6 +12,8 @@ import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label";
import { Trash2 } from "lucide-react";
import RowIconButton from "./row-icon-button";
import TaskStatusChip from "./task-status-chip";
type Props = {
@@ -24,6 +26,10 @@ type Props = {
* Moves it to one status; only crew managers get the controls.
*/
onStatusChange?: (status: string) => void;
/**
* Deletes it after asking; only crew managers get the button.
*/
onDelete?: () => void;
busy?: boolean;
};
@@ -32,7 +38,7 @@ type Props = {
* is and who has it, what it's blocked on, its latest note and how long ago it
* moved.
*/
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled";
@@ -61,6 +67,14 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button>
<TaskStatusChip status={task.status} />
{can_manage && onDelete ? (
<RowIconButton
icon={Trash2}
title={`Delete ${task.title || `task ${task.id}`}`}
disabled={busy}
onClick={onDelete}
/>
) : null}
</Row>
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
+14 -8
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone =
value == "blocked"
? "text-ink border-ink"
: value == "doing"
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
/**
* A `Tag` is a filled primary button by default (ink in light mode), so
* the chip clears its fill and sets its own text: only the outline speaks.
*/
return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
<Tag
className={twMerge(
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
value == "blocked" && "text-ink! dark:text-ink! border-ink",
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
)}
>
{title}
</Tag>
);
+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 };
}
@@ -80,6 +80,29 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
replace({ task: res.singleRes });
};
const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
set_busy(true);
set_status(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
set_busy(false);
if (!res.success) {
set_status(res.msg || "That didn't work.");
return;
}
setTasks((prev) => prev.filter((item) => item.id != task.id));
set_status("Task deleted.");
};
const show = async ({ task }: { task: TaskWithNames }) => {
set_status(undefined);
@@ -220,6 +243,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/>
))
) : (
@@ -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}
</>
@@ -62,6 +62,29 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
replace({ task: res.singleRes });
};
const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
setItems((prev) => prev.filter((item) => item.id != task.id));
setStatus({ success: true, msg: "Task deleted." });
};
const show = async ({ task }: { task: TaskWithNames }) => {
setStatus(undefined);
@@ -142,6 +165,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/>
))}
</SettingsList>