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", "name": "cobble",
"version": "0.7.1", "version": "0.7.3",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label"; 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"; import TaskStatusChip from "./task-status-chip";
type Props = { type Props = {
@@ -24,6 +26,10 @@ type Props = {
* Moves it to one status; only crew managers get the controls. * Moves it to one status; only crew managers get the controls.
*/ */
onStatusChange?: (status: string) => void; onStatusChange?: (status: string) => void;
/**
* Deletes it after asking; only crew managers get the button.
*/
onDelete?: () => void;
busy?: boolean; 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 * is and who has it, what it's blocked on, its latest note and how long ago it
* moved. * 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 { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled"; 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> <Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button> </Button>
<TaskStatusChip status={task.status} /> <TaskStatusChip status={task.status} />
{can_manage && onDelete ? (
<RowIconButton
icon={Trash2}
title={`Delete ${task.title || `task ${task.id}`}`}
disabled={busy}
onClick={onDelete}
/>
) : null}
</Row> </Row>
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate"> <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 Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict"; 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 value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do"; const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone = /**
value == "blocked" * A `Tag` is a filled primary button by default (ink in light mode), so
? "text-ink border-ink" * the chip clears its fill and sets its own text: only the outline speaks.
: value == "doing" */
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
return ( 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} {title}
</Tag> </Tag>
); );
+21 -1
View File
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
export const PwaCornerMarkInset = 0.2; export const PwaCornerMarkInset = 0.2;
export const PwaCornerMarkScale = 0.22; 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 * Uploads must be at least this many pixels on their shorter side, and are
* kept as a square of the source size. * kept as a square of the source size.
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
export const PwaInstallIconMinSide = 512; export const PwaInstallIconMinSide = 512;
export const PwaInstallIconSourceSide = 1024; 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 = { export type PwaInstallIconFile = {
name: string; name: string;
size: number; size: number;
/** /**
* `plain` is the image without the mark (the settings preview); * `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"; kind: "any" | "maskable" | "favicon" | "plain";
}; };
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
PwaInstallIconRenderRevision,
PwaInstallIconSettingKey, PwaInstallIconSettingKey,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconMode, type PwaInstallIconMode,
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
const version = is_stock const version = is_stock
? null ? null
: createHash("sha256") : createHash("sha256")
.update(JSON.stringify({ mode, stone, mark })) .update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
.update(source || "") .update(source || "")
.digest("hex") .digest("hex")
.slice(0, 12); .slice(0, 12);
@@ -1,10 +1,14 @@
import sharp from "sharp"; import sharp, { type OverlayOptions } from "sharp";
import { import {
PwaAppIconMarkScale, PwaAppIconMarkScale,
PwaCornerMarkInset, PwaCornerMarkInset,
PwaCornerMarkScale, PwaCornerMarkScale,
PwaFaviconCornerMarkInset,
PwaFaviconCornerMarkScale,
PwaInstallIconFiles, PwaInstallIconFiles,
PwaMaskableMarkScale, PwaMaskableMarkScale,
PwaSquareCornerMarkInset,
PwaSquareCornerMarkScale,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconFile, type PwaInstallIconFile,
} from "@/src/data/pwa-data"; } from "@/src/data/pwa-data";
@@ -26,7 +30,8 @@ type Params = {
/** /**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name. * 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 * 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) { export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {}; const files: Record<string, Buffer> = {};
@@ -79,19 +84,41 @@ async function renderImageFile({
.resize(file.size, file.size, { fit: "cover" }) .resize(file.size, file.size, { fit: "cover" })
.flatten({ background }); .flatten({ background });
if (!mark || file.kind == "favicon" || file.kind == "plain") { const layers: OverlayOptions[] = [];
return base.png().toBuffer();
}
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale)); if (mark && file.kind != "plain") {
const inset = Math.round(file.size * PwaCornerMarkInset); 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) const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" }) .resize(mark_size, mark_size, { fit: "contain" })
.png() .png()
.toBuffer(); .toBuffer();
return base layers.push({ input: stone, left: inset, top: inset });
.composite([{ input: stone, left: inset, top: inset }]) }
.png()
.toBuffer(); 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 }); 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 }) => { const show = async ({ task }: { task: TaskWithNames }) => {
set_status(undefined); set_status(undefined);
@@ -220,6 +243,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
busy={busy} busy={busy}
onOpen={() => show({ task }).catch(() => {})} onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })} 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 * 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, * 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) { export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
const stone_src = `/brand/icons/${stone}.svg`; const stone_src = `/brand/icons/${stone}.svg`;
@@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src,
<Img <Img
src={stone_src} src={stone_src}
alt="" 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} ) : null}
</> </>
@@ -62,6 +62,29 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
replace({ task: res.singleRes }); 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 }) => { const show = async ({ task }: { task: TaskWithNames }) => {
setStatus(undefined); setStatus(undefined);
@@ -142,6 +165,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
busy={busy} busy={busy}
onOpen={() => show({ task }).catch(() => {})} onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })} onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/> />
))} ))}
</SettingsList> </SettingsList>