diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index c64d3267..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-name: ๐ Bug Report
-about: Create a report to help us improve
-title: '[๐ BUG] '
-labels: bug
-assignees: ''
----
-
-**Describe the bug**
-A clear description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. See error
-
-**Expected behavior**
-What you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots.
-
-**Environment:**
- - macOS Version: [e.g. Sonoma 14.2, Ventura 13.5]
- - App Version: [e.g. 1.2.3]
- - Architecture: [Intel/Apple Silicon]
-
-**Additional context**
-Add any other context about the problem here.
-
-**Crash Logs**
-If the app crashed, please attach relevant crash logs
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..2c3ce743
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,83 @@
+name: Bug Report
+description: Report a reproducible FluidVoice bug.
+title: "[BUG] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ If you are unsure whether this is a FluidVoice bug, please start a Discussion first:
+ https://github.com/altic-dev/FluidVoice/discussions
+ - type: textarea
+ id: description
+ attributes:
+ label: Describe the bug
+ description: Briefly describe what is broken.
+ placeholder: FluidVoice crashes when...
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Reproduction steps
+ description: Provide exact steps a maintainer can follow.
+ placeholder: |
+ 1. Open FluidVoice
+ 2. Configure...
+ 3. Press...
+ 4. Observe...
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: What should have happened?
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ description: What happened instead?
+ validations:
+ required: true
+ - type: input
+ id: app-version
+ attributes:
+ label: App Version
+ placeholder: "1.2.3"
+ validations:
+ required: true
+ - type: input
+ id: macos-version
+ attributes:
+ label: macOS Version
+ placeholder: "macOS 15.5"
+ validations:
+ required: true
+ - type: dropdown
+ id: architecture
+ attributes:
+ label: Architecture
+ options:
+ - Apple Silicon
+ - Intel
+ - Unknown
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs or crash report
+ description: Paste relevant logs or crash output when available.
+ render: text
+ validations:
+ required: false
+ - type: upload
+ id: media
+ attributes:
+ label: Screenshots or screen recording
+ description: Attach screenshots or a recording if they help explain the bug.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..193469cc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Start a discussion or ask a question
+ url: https://github.com/altic-dev/FluidVoice/discussions
+ about: Use Discussions for support questions, unclear reports, ideas, and open-ended design conversations.
+ - name: Propose a feature idea
+ url: https://github.com/altic-dev/FluidVoice/discussions/new?category=ideas
+ about: Feature ideas should start as Discussions before they become prioritized issues.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 7836b285..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-name: โจ Feature Request
-about: Suggest an idea for this project
-title: '[โจ FEATURE] '
-labels: enhancement
-assignees: ''
----
-
-**Is your feature request related to a problem?**
-A clear description of the problem.
-
-**Describe the solution you'd like**
-What you want to happen.
-
-**Describe alternatives you've considered**
-Other solutions or features you've considered.
-
-**Additional context**
-Any other context or screenshots.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..8c61402a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,36 @@
+name: Feature Request
+description: Suggest an idea for FluidVoice.
+title: "[FEATURE] "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Feature ideas should start in Discussions so maintainers and users can shape the request before it becomes tracked work.
+
+ Open a feature idea Discussion here:
+ https://github.com/altic-dev/FluidVoice/discussions/new?category=ideas
+
+ Use this issue form only for features that already have maintainer acceptance, roadmap priority, or a linked Discussion that has been accepted.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What user problem does this solve?
+ placeholder: Users currently cannot...
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: Describe the requested behavior.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ description: Mention workarounds or other approaches that were discussed.
+ validations:
+ required: false
diff --git a/.github/policy/enforce-issue-intake.mjs b/.github/policy/enforce-issue-intake.mjs
new file mode 100644
index 00000000..e5dd2d44
--- /dev/null
+++ b/.github/policy/enforce-issue-intake.mjs
@@ -0,0 +1,123 @@
+import { readFileSync } from "node:fs";
+
+import { validateBugIssue } from "./github-policy.mjs";
+import {
+ addLabels,
+ ensureLabel,
+ githubRequest,
+ paginate,
+ removeLabel,
+ repoContext,
+ upsertIssueComment,
+} from "./github-api.mjs";
+
+const BUG_MARKER = "";
+const STALE_MARKER = "";
+const STALE_REPRODUCTION_DAYS = 14;
+
+const LABELS = {
+ needsReproduction: {
+ name: "needs reproduction",
+ color: "D93F0B",
+ description: "Bug report needs enough detail to reproduce.",
+ },
+};
+
+function eventPayload() {
+ return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
+}
+
+function missingFieldsText(fields) {
+ return fields.map((field) => `- ${field}`).join("\n");
+}
+
+async function existingBugWarningComment(context, issueNumber) {
+ const comments = await paginate(
+ `/repos/${context.owner}/${context.repo}/issues/${issueNumber}/comments`,
+ );
+ return comments.find((comment) => comment.body?.includes(BUG_MARKER));
+}
+
+async function enforceBugIssue(context, issue) {
+ const result = validateBugIssue(issue.body ?? "");
+
+ if (result.ok) {
+ await removeLabel(context, issue.number, LABELS.needsReproduction.name);
+ return;
+ }
+
+ await addLabels(context, issue.number, [LABELS.needsReproduction.name]);
+ await upsertIssueComment(
+ context,
+ issue.number,
+ BUG_MARKER,
+ [
+ "This bug report is missing information required for maintainers to reproduce it.",
+ "",
+ "Please update the issue with:",
+ missingFieldsText(result.missing),
+ "",
+ `Issues still missing reproduction details after ${STALE_REPRODUCTION_DAYS} days may be closed.`,
+ ].join("\n"),
+ );
+}
+
+async function closeStaleReproductionIssues(context) {
+ const staleBefore = new Date(
+ Date.now() - STALE_REPRODUCTION_DAYS * 24 * 60 * 60 * 1000,
+ )
+ .toISOString()
+ .slice(0, 10);
+ const query = encodeURIComponent(
+ `repo:${context.owner}/${context.repo} is:issue is:open label:"${LABELS.needsReproduction.name}" updated:<${staleBefore}`,
+ );
+ const results = await githubRequest("GET", `/search/issues?q=${query}&per_page=100`);
+
+ for (const issue of results.items) {
+ const warningComment = await existingBugWarningComment(context, issue.number);
+ if (!warningComment) continue;
+
+ await upsertIssueComment(
+ context,
+ issue.number,
+ STALE_MARKER,
+ [
+ `Closing this issue because it has been labeled \`needs reproduction\` for at least ${STALE_REPRODUCTION_DAYS} days without the missing reproduction details.`,
+ "",
+ "If you can still reproduce the problem, please open a new bug report with exact steps, expected behavior, actual behavior, app version, macOS version, and architecture.",
+ ].join("\n"),
+ );
+ await githubRequest("PATCH", `/repos/${context.owner}/${context.repo}/issues/${issue.number}`, {
+ state: "closed",
+ state_reason: "not_planned",
+ });
+ }
+}
+
+async function main() {
+ const context = repoContext();
+ await Promise.all(Object.values(LABELS).map((label) => ensureLabel(context, label)));
+
+ const eventName = process.env.GITHUB_EVENT_NAME;
+ if (eventName === "schedule" || eventName === "workflow_dispatch") {
+ await closeStaleReproductionIssues(context);
+ return;
+ }
+
+ const event = eventPayload();
+ const issue = event.issue;
+ if (!issue || issue.pull_request) return;
+
+ const warningComment = await existingBugWarningComment(context, issue.number);
+ if (event.action !== "opened" && !warningComment) return;
+
+ const labels = issue.labels.map((label) =>
+ typeof label === "string" ? label : label.name,
+ );
+
+ if (labels.includes("bug")) {
+ await enforceBugIssue(context, issue);
+ }
+}
+
+await main();
diff --git a/.github/policy/enforce-pr-policy.mjs b/.github/policy/enforce-pr-policy.mjs
new file mode 100644
index 00000000..05aec283
--- /dev/null
+++ b/.github/policy/enforce-pr-policy.mjs
@@ -0,0 +1,159 @@
+import { readFileSync } from "node:fs";
+
+import { validatePullRequest } from "./github-policy.mjs";
+import {
+ addLabels,
+ ensureLabel,
+ githubRequest,
+ paginate,
+ removeLabel,
+ repoContext,
+ upsertIssueComment,
+} from "./github-api.mjs";
+
+const MARKER = "";
+const HOURS_BEFORE_CLOSE = 48;
+
+const LABELS = {
+ template: {
+ name: "needs PR template",
+ color: "D93F0B",
+ description: "Pull request is missing required template content.",
+ },
+ screenshots: {
+ name: "needs screenshots",
+ color: "FBCA04",
+ description: "Pull request needs screenshot or video evidence.",
+ },
+};
+
+function eventPayload() {
+ return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
+}
+
+function hoursSince(value) {
+ return (Date.now() - new Date(value).getTime()) / 36e5;
+}
+
+async function changedFiles(context, pullNumber) {
+ const { owner, repo } = context;
+ const files = await paginate(`/repos/${owner}/${repo}/pulls/${pullNumber}/files`);
+ return files.map((file) => file.filename);
+}
+
+async function existingWarningComment(context, pullNumber) {
+ const comments = await paginate(
+ `/repos/${context.owner}/${context.repo}/issues/${pullNumber}/comments`,
+ );
+ return comments.find((comment) => comment.body?.includes(MARKER));
+}
+
+function failureBody(result) {
+ const missingList = result.missing.map((field) => `- ${field}`).join("\n");
+ const visualFiles = result.visualFiles.length
+ ? `\n\nVisual files detected:\n${result.visualFiles.map((file) => `- \`${file}\``).join("\n")}`
+ : "";
+
+ return [
+ "The PR Policy check is blocking this PR because required template information is missing.",
+ "",
+ "Please update the PR description with:",
+ missingList,
+ visualFiles,
+ "",
+ "Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template.",
+ "",
+ `If this remains incomplete for ${HOURS_BEFORE_CLOSE} hours after opening, the PR may be closed.`,
+ ].join("\n");
+}
+
+async function enforceOne(context, pr, { closeExpired, failOnInvalid, warningComment }) {
+ const files = await changedFiles(context, pr.number);
+ const result = validatePullRequest({ body: pr.body ?? "", changedFiles: files });
+
+ if (result.ok) {
+ await removeLabel(context, pr.number, LABELS.template.name);
+ await removeLabel(context, pr.number, LABELS.screenshots.name);
+ return true;
+ }
+
+ const labelsToAdd = [];
+ if (result.missing.some((field) => field !== "Screenshots / Video")) {
+ labelsToAdd.push(LABELS.template.name);
+ }
+ if (result.missing.includes("Screenshots / Video")) {
+ labelsToAdd.push(LABELS.screenshots.name);
+ }
+
+ warningComment ??= await existingWarningComment(context, pr.number);
+ await addLabels(context, pr.number, labelsToAdd);
+ await upsertIssueComment(context, pr.number, MARKER, failureBody(result));
+
+ const expired =
+ !pr.draft &&
+ warningComment?.created_at &&
+ hoursSince(warningComment.created_at) >= HOURS_BEFORE_CLOSE;
+ if (closeExpired && expired) {
+ await githubRequest("PATCH", `/repos/${context.owner}/${context.repo}/pulls/${pr.number}`, {
+ state: "closed",
+ });
+ await upsertIssueComment(
+ context,
+ pr.number,
+ MARKER,
+ [
+ "This PR has been closed because it still does not follow the required PR template after the 48-hour correction window.",
+ "",
+ "Please open a new PR with the required description, related issue or accepted Discussion, testing notes, and screenshot/video evidence when applicable.",
+ ].join("\n"),
+ );
+ }
+
+ if (failOnInvalid) {
+ console.error(`PR #${pr.number} is missing: ${result.missing.join(", ")}`);
+ process.exitCode = 1;
+ }
+
+ return false;
+}
+
+async function openPullRequests(context) {
+ return paginate(`/repos/${context.owner}/${context.repo}/pulls?state=open`);
+}
+
+async function main() {
+ const context = repoContext();
+ await Promise.all(Object.values(LABELS).map((label) => ensureLabel(context, label)));
+
+ const eventName = process.env.GITHUB_EVENT_NAME;
+
+ if (eventName === "schedule" || eventName === "workflow_dispatch") {
+ const pulls = await openPullRequests(context);
+ for (const pr of pulls) {
+ const warningComment = await existingWarningComment(context, pr.number);
+ if (!warningComment) continue;
+
+ await enforceOne(context, pr, {
+ closeExpired: true,
+ failOnInvalid: false,
+ warningComment,
+ });
+ }
+ return;
+ }
+
+ const event = eventPayload();
+ const warningComment = await existingWarningComment(context, event.pull_request.number);
+
+ if (event.action !== "opened" && !warningComment) {
+ return;
+ }
+
+ await enforceOne(context, event.pull_request, {
+ closeExpired: true,
+ failOnInvalid: true,
+ warningComment,
+ });
+}
+
+await main();
diff --git a/.github/policy/github-api.mjs b/.github/policy/github-api.mjs
new file mode 100644
index 00000000..8fb7c318
--- /dev/null
+++ b/.github/policy/github-api.mjs
@@ -0,0 +1,111 @@
+const API_VERSION = "2022-11-28";
+
+export function repoContext() {
+ const repository = process.env.GITHUB_REPOSITORY;
+ if (!repository) throw new Error("GITHUB_REPOSITORY is required");
+
+ const [owner, repo] = repository.split("/");
+ if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
+
+ return { owner, repo };
+}
+
+export async function githubRequest(method, path, body) {
+ const token = process.env.GITHUB_TOKEN;
+ if (!token) throw new Error("GITHUB_TOKEN is required");
+
+ const response = await fetch(`https://api.github.com${path}`, {
+ method,
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ "X-GitHub-Api-Version": API_VERSION,
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+
+ if (response.status === 204) return null;
+
+ const text = await response.text();
+ const data = text ? JSON.parse(text) : null;
+
+ if (!response.ok) {
+ const message = data?.message ?? response.statusText;
+ const error = new Error(`${method} ${path} failed: ${message}`);
+ error.status = response.status;
+ error.data = data;
+ throw error;
+ }
+
+ return data;
+}
+
+export async function ignoreNotFound(promise) {
+ try {
+ return await promise;
+ } catch (error) {
+ if (error.status === 404) return null;
+ throw error;
+ }
+}
+
+export async function paginate(path) {
+ const results = [];
+ let page = 1;
+
+ while (true) {
+ const separator = path.includes("?") ? "&" : "?";
+ const data = await githubRequest(
+ "GET",
+ `${path}${separator}per_page=100&page=${page}`,
+ );
+ results.push(...data);
+ if (data.length < 100) break;
+ page += 1;
+ }
+
+ return results;
+}
+
+export async function ensureLabel({ owner, repo }, label) {
+ const encoded = encodeURIComponent(label.name);
+ const existing = await ignoreNotFound(
+ githubRequest("GET", `/repos/${owner}/${repo}/labels/${encoded}`),
+ );
+
+ if (existing) return;
+
+ await githubRequest("POST", `/repos/${owner}/${repo}/labels`, label);
+}
+
+export async function addLabels({ owner, repo }, issueNumber, labels) {
+ if (labels.length === 0) return;
+ await githubRequest("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
+ labels,
+ });
+}
+
+export async function removeLabel({ owner, repo }, issueNumber, label) {
+ const encoded = encodeURIComponent(label);
+ await ignoreNotFound(
+ githubRequest("DELETE", `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encoded}`),
+ );
+}
+
+export async function upsertIssueComment({ owner, repo }, issueNumber, marker, body) {
+ const comments = await paginate(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`);
+ const existing = comments.find((comment) => comment.body?.includes(marker));
+ const markedBody = `${marker}\n${body}`;
+
+ if (existing) {
+ await githubRequest("PATCH", `/repos/${owner}/${repo}/issues/comments/${existing.id}`, {
+ body: markedBody,
+ });
+ return;
+ }
+
+ await githubRequest("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
+ body: markedBody,
+ });
+}
diff --git a/.github/policy/github-policy.mjs b/.github/policy/github-policy.mjs
new file mode 100644
index 00000000..149d4d6a
--- /dev/null
+++ b/.github/policy/github-policy.mjs
@@ -0,0 +1,160 @@
+const PLACEHOLDER_PATTERNS = [
+ /^brief description of what this pr does(?: and why it is needed)?\.?$/i,
+ /^add screenshots or video recording.*$/i,
+ /^attach screenshots or a video.*$/i,
+ /^closes #?\(issue number.*$/i,
+ /^what you expected to happen\.?$/i,
+ /^what actually happened\.?$/i,
+ /^steps to reproduce:?$/i,
+ /^\s*(n\/a|none|todo|tbd|\.\.\.)\s*$/i,
+];
+
+const VISUAL_PATH_PATTERNS = [
+ /^Sources\/Fluid\/UI\//,
+ /^Sources\/Fluid\/Views\//,
+ /^Sources\/Fluid\/Theme\//,
+ /^Sources\/Fluid\/Assets\.xcassets\//,
+ /^\.github\/screenshots\//,
+ /(^|\/)([^/]*View|[^/]*Views|Settings|Onboarding|Overlay|MenuBar|Icon|Animation|Animations)[^/]*\.swift$/,
+ /\.xcassets\//,
+];
+
+const MEDIA_PATTERNS = [
+ /!\[[^\]]*]\([^)]+\)/i,
+ /
]*>/i,
+ /