diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx
index 84a2bd07b..00789bfaa 100644
--- a/apps/docs/docs/management/workshop/index.mdx
+++ b/apps/docs/docs/management/workshop/index.mdx
@@ -27,9 +27,10 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
-- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, reports, comments, and full content.
+- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
-- Reports and the manual **Outdated** badge are warnings, not automatic removal. Reported and outdated entries remain installable after confirmation.
+- Report details and reporter identities are visible only to Workshop administrators. Reports do not automatically remove a submission.
+- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
@@ -38,7 +39,7 @@ Comments are publicly readable. Signed-in users can post; authors can edit or de
## Submitting
1. Sign in with GitHub.
-2. Click **Submit** and choose the type:
+2. Click **Share yours** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
@@ -62,8 +63,7 @@ You can install workshop entries without leaving Homarr:
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
-Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
-manually.
+Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts
index 2995580ee..230644992 100644
--- a/apps/docs/docusaurus.config.ts
+++ b/apps/docs/docusaurus.config.ts
@@ -46,17 +46,6 @@ const config: Config = {
themes: ["@docusaurus/theme-mermaid"],
- scripts: [
- {
- src: "https://widget.kapa.ai/kapa-widget.bundle.js",
- "data-website-id": "1e4656f4-abeb-4343-bbae-1d8626f52378",
- "data-project-name": "Homarr",
- "data-project-color": "#2B2B2B",
- "data-project-logo": "https://homarr.dev/img/favicon.png",
- async: true,
- },
- ],
-
presets: [
[
"classic",
diff --git a/apps/docs/src/components/custom-widget-code-client.tsx b/apps/docs/src/components/custom-widget-code-client.tsx
index f057400dc..ce35fc60a 100644
--- a/apps/docs/src/components/custom-widget-code-client.tsx
+++ b/apps/docs/src/components/custom-widget-code-client.tsx
@@ -1,19 +1,8 @@
import { useState } from "react";
+import { useColorMode } from "@docusaurus/theme-common";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
-import "@mantine/core/styles/Badge.css";
-import "@mantine/core/styles/Button.css";
-import "@mantine/core/styles/Combobox.css";
-import "@mantine/core/styles/Group.css";
-import "@mantine/core/styles/Input.css";
-import "@mantine/core/styles/Loader.css";
-import "@mantine/core/styles/Pill.css";
-import "@mantine/core/styles/PillsInput.css";
-import "@mantine/core/styles/Popover.css";
-import "@mantine/core/styles/ScrollArea.css";
-import "@mantine/core/styles/Stack.css";
-import "@mantine/core/styles/Text.css";
-import "@mantine/core/styles/UnstyledButton.css";
+import "@mantine/core/styles.layer.css";
/* oxlint-enable import/no-unassigned-import */
import {
@@ -64,8 +53,10 @@ export function CanonicalCustomWidgetExampleClient({ id, label, example, height
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
+ const { colorMode } = useColorMode();
+
return (
-
Loading comments…
} + {loading && ( +Failed to load comments
} {!loading && !fetchError && rows.length === 0 && (No comments yet
@@ -165,6 +174,7 @@ export const CommentsSection = ({ {canManage && !isEditing && (Check the Workshop service and try again.
+
{
Changelog Changelog {submission.changelog}
- The community has reported this submission {reports.length} time{reports.length === 1 ? "" : "s"}.
- Install in Homarr
+ {submission.type === "customWidget"
+ ? "Download the JSON, then open Manage → Custom Widgets → Import in your Homarr instance. Homarr asks for any required credentials during import."
+ : "Copy the CSS, then open your board settings and paste it into Custom CSS."}{" "}
+
+ Read the installation guide
+
+ Community custom widgets and CSS themes for Homarr.
+ Discover community-made widgets and CSS. Review the source, then import it into Homarr.
+ Vote and share your creations Vote, comment, report, and publish
+ Check the Workshop service and your connection, then try again.
+ {empty.title} {empty.hint}
+ Reload the page to try again. Your Homarr installation is not affected.
+ Hi there,
+ {{commenterName}} left a comment on your submission
+ {{submissionTitle}}:
+ ${escapeHtml(commenterName)} commented on ${escapeHtml(submissionTitle)}:
- {reports.map((report) => (
-
-
Workshop
- Workshop listings could not be loaded
+ Workshop could not be displayed
+
+
+
+
diff --git a/apps/workshop/pb_hooks/workshop.pb.js b/apps/workshop/pb_hooks/workshop.pb.js
index 4b54fe0fc..4e266a048 100644
--- a/apps/workshop/pb_hooks/workshop.pb.js
+++ b/apps/workshop/pb_hooks/workshop.pb.js
@@ -40,12 +40,20 @@ onRecordAfterCreateSuccess((event) => {
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const sender = event.app.settings().meta;
+ const templatePath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
+ const template = $os
+ .readFile($filepath.join(templatePath, "comment-email.html"))
+ .replaceAll("{{commenterName}}", escapeHtml(commenterName))
+ .replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
+ .replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
+ .replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
+
const message = new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject: `${commenterName} commented on ${submissionTitle}`,
text: `${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
- html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{commentExcerpt}}
+
+
+
+
+
+
+
+
+
+ VIEW THE COMMENT
+
+
+
+
+
+
+
+
+
+
+ homarr.dev
+ · Homarr — 2026
+
+ ${escapeHtml(rawExcerpt)}
`,
+ html: template,
});
event.app.newMailClient().send(message);
} catch (error) {
diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
index e008bcd25..9c8f51f72 100644
--- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
+++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
@@ -135,8 +135,8 @@ migrate(
const reports = new Collection({
type: "base",
name: "reports",
- listRule: "",
- viewRule: "",
+ listRule: adminRule,
+ viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
deleteRule: adminRule,
diff --git a/packages/custom-widgets/src/workbench/code-editor.module.css b/packages/custom-widgets/src/workbench/code-editor.module.css
index 565976940..f1569bf88 100644
--- a/packages/custom-widgets/src/workbench/code-editor.module.css
+++ b/packages/custom-widgets/src/workbench/code-editor.module.css
@@ -1,8 +1,8 @@
.root {
margin-top: 4px;
- border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
+ border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
- background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
+ background: var(--mantine-color-body);
overflow: hidden;
}
@@ -15,7 +15,7 @@
.toolbar,
.footer {
padding: 7px 10px;
- background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
+ background: var(--mantine-color-default-hover);
}
.toolbar {
@@ -29,8 +29,8 @@
overflow: auto;
}
.viewport :global(.cm-editor) {
- background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
- color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
+ background: var(--mantine-color-body);
+ color: var(--mantine-color-text);
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
@@ -42,7 +42,7 @@
overflow: auto;
}
.viewport :global(.cm-gutters) {
- background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
+ background: var(--mantine-color-default-hover);
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
diff --git a/packages/workshop/src/schema.spec.ts b/packages/workshop/src/schema.spec.ts
index 7d2671b7c..68cb85cd0 100644
--- a/packages/workshop/src/schema.spec.ts
+++ b/packages/workshop/src/schema.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
-import { validateWorkshopWidget, workshopSubmissionInputSchema } from "./schema";
+import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
describe("Workshop widget validation", () => {
test("accepts a canonical credential-free widget", () => {
@@ -55,6 +55,25 @@ describe("Workshop widget validation", () => {
).toBe(false);
});
+ test("normalizes PocketBase records before rendering", () => {
+ const result = workshopSubmissionSummarySchema.parse({
+ id: "jzyxskmimxn42io",
+ title: "Tautulli Activity",
+ description: "Shows active Plex streams from Tautulli",
+ widgetSchema: "homarr-custom-widget-v2",
+ screenshots: "[]",
+ author: "wsqylmotymtqpox",
+ revision: 1,
+ created: "2026-07-21 14:19:25.790Z",
+ updated: "2026-07-21 14:19:25.790Z",
+ });
+
+ expect(result.type).toBe("customWidget");
+ expect(result.screenshots).toEqual([]);
+ expect(result.authorName).toBe("Community member");
+ expect(result.upvotes).toBe(0);
+ });
+
test("rejects literal credentials in templates and icon URLs", () => {
expect(
validateWorkshopWidget(
diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts
index e1ff627ec..0aea69d41 100644
--- a/packages/workshop/src/schema.ts
+++ b/packages/workshop/src/schema.ts
@@ -36,7 +36,17 @@ export const workshopUserSchema = z.object({
});
export type WorkshopUser = z.infer