chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-17 08:04:30 +00:00
parent 818c0f7c53
commit b1aaa69f0a
286 changed files with 51076 additions and 67 deletions
+4 -2
View File
@@ -45,7 +45,9 @@
# Wee, Sherif - proper name (Wee, Sherif, contributor names should not be flagged as typos)
# queston - intentional misspelling example in skills/arize-dataset/SKILL.md demonstrating typo detection in field names
# extenions - intentional misspelled key name used in plugin validators to detect invalid manifests
# nin - MongoDB $nin operator in security instructions NoSQL injection detection regex
# Vertexes - FreeCAD shape sub-elements used as property of obj.Shape
@@ -60,7 +62,7 @@
# Vally/vally - Name of product
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally
# Skip certain files and directories
# *.tm7 - MTM DataContract exports; embedded base64 icon blobs trigger false positives
+18 -18
View File
@@ -13,7 +13,7 @@
"name": "accessibility-kanban",
"source": "extensions/accessibility-kanban",
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "acreadiness-cockpit",
@@ -85,13 +85,13 @@
"name": "apng-studio",
"source": "extensions/apng-studio",
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "arcade-canvas",
"source": "extensions/arcade-canvas",
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "arch",
@@ -158,7 +158,7 @@
"name": "backlog-swipe-triage",
"source": "extensions/backlog-swipe-triage",
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "cast-imaging",
@@ -195,7 +195,7 @@
"name": "chromium-control-canvas",
"source": "extensions/chromium-control-canvas",
"description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "clojure-interactive-programming",
@@ -239,13 +239,13 @@
"name": "color-orb",
"source": "extensions/color-orb",
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "connector-namespaces",
"source": "extensions/connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.0"
"version": "1.1.1"
},
{
"name": "context-engineering",
@@ -373,7 +373,7 @@
"name": "diagram-viewer",
"source": "extensions/diagram-viewer",
"description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "dotnet",
@@ -520,7 +520,7 @@
"name": "feedback-themes",
"source": "extensions/feedback-themes",
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "figma",
@@ -593,7 +593,7 @@
"name": "gesture-review",
"source": "extensions/gesture-review",
"description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "gh-skills-builder",
@@ -701,7 +701,7 @@
"name": "java-modernization-studio",
"source": "extensions/java-modernization-studio",
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "kotlin-mcp-development",
@@ -980,13 +980,13 @@
"name": "release-notes-showcase",
"source": "extensions/release-notes-showcase",
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "repo-actions-hub",
"source": "extensions/repo-actions-hub",
"description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "roundup",
@@ -1028,7 +1028,7 @@
"name": "site-studio",
"source": "extensions/site-studio",
"description": "Plan, draft, and track a personal website section by section — a shared canvas where you and your agent author content, watch progress, and review every change.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "skill-image-gen",
@@ -1118,13 +1118,13 @@
"name": "tiny-tool-town-submitter",
"source": "extensions/tiny-tool-town-submitter",
"description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "token-pacman",
"source": "extensions/token-pacman",
"description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "typescript-mcp-development",
@@ -1311,7 +1311,7 @@
"name": "where-was-i",
"source": "extensions/where-was-i",
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "winappcli",
@@ -1375,7 +1375,7 @@
"name": "work-hub",
"source": "extensions/work-hub",
"description": "Generic cross-repo command center canvas for GitHub Copilot with onboarding, focus planning, repo health, work signals, and session cleanup.",
"version": "1.0.0"
"version": "1.0.1"
}
]
}
@@ -753,6 +753,7 @@ jobs:
vally_lint_status: 'infra_error',
smoke_status: 'infra_error',
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
failure_class: 'infra',
summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.',
};
@@ -764,6 +765,7 @@ jobs:
vally_lint_status: 'infra_error',
smoke_status: 'infra_error',
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
failure_class: 'infra',
summary: 'Quality-gate workflow did not return results. Re-run intake to retry.',
};
@@ -116,6 +116,7 @@ jobs:
vally_lint_status: 'infra_error',
smoke_status: 'infra_error',
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
failure_class: 'infra',
summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.',
};
@@ -127,6 +128,7 @@ jobs:
vally_lint_status: 'infra_error',
smoke_status: 'infra_error',
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
failure_class: 'infra',
summary: 'Quality-gate workflow did not return results. Re-run intake to retry.',
};
@@ -158,6 +158,7 @@ jobs:
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR change detection failed unexpectedly. Re-run this workflow.',
};
} else if (shouldRun) {
@@ -167,6 +168,7 @@ jobs:
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR quality checks failed unexpectedly. Re-run this workflow.',
};
} else if (process.env.QUALITY_RESULT_JSON) {
@@ -177,6 +179,7 @@ jobs:
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR quality checks did not return a result payload.',
};
}
@@ -245,15 +248,16 @@ jobs:
const sourceUrl = String(entry?.source_tree_url || '');
const locator = String(entry?.source?.sha || entry?.source?.ref || 'repository');
const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator;
return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`;
return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.canvas_structure_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`;
})
: ['| _none_ | not_run | not_run | not_run | not_run | _n/a_ |'];
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
const failureDetails = checkedPlugins.flatMap((entry) => {
const name = String(entry?.name || 'unknown');
const quality = entry?.quality || {};
const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0;
const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0;
const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0;
const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0;
const details = [];
if (shouldShowVally) {
@@ -265,6 +269,9 @@ jobs:
if (shouldShowVersionMatch) {
details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output));
}
if (shouldShowCanvasStructure) {
details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output));
}
return details;
});
@@ -277,8 +284,8 @@ jobs:
'',
'### Per-plugin quality summary',
'',
'| Plugin | vally lint | install smoke test | version match | overall | source tree |',
'|---|---|---|---|---|---|',
'| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |',
'|---|---|---|---|---|---|---|',
...rows,
'',
...(failureDetails.length > 0
+1 -1
View File
@@ -89,7 +89,7 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must
- Extension `plugin.json` **must** follow the convention:
- `name`, `description`, `version` are required
- `logo` **must** be exactly `"assets/preview.png"` (enforced convention)
- `extensions` **must** be exactly `"."` (per [copilot-agent-runtime#9929](https://github.com/github/copilot-agent-runtime/pull/9929))
- `extensions` **must** be exactly `"."` in source manifests (materialization rewrites this to `"extensions"` for distribution output)
- Optional: `author`, `keywords` fields
- **Must not** include `x-awesome-copilot` field (use convention-based `assets/preview.png` only)
- Each extension must have `assets/preview.png` as the primary visual asset
+44
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
const MATERIALIZED_SPECS = {
agents: {
path: "agents",
@@ -89,6 +90,30 @@ function cleanPlugin(pluginPath) {
return { removed, manifestUpdated };
}
function cleanMaterializedExtensionPlugin(extensionPath) {
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
let manifestUpdated = false;
if (fs.existsSync(pluginJsonPath)) {
const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
if (plugin.extensions === "extensions") {
plugin.extensions = ".";
fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8");
manifestUpdated = true;
console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`);
}
}
const target = path.join(extensionPath, "extensions");
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
return { removed: 0, manifestUpdated };
}
const count = countFiles(target);
fs.rmSync(target, { recursive: true, force: true });
console.log(` Removed ${path.basename(extensionPath)}/extensions/ (${count} files)`);
return { removed: count, manifestUpdated };
}
function countFiles(dir) {
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -171,6 +196,25 @@ function main() {
}
}
if (fs.existsSync(EXTENSIONS_DIR)) {
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const dirName of extensionDirs) {
const extensionPath = path.join(EXTENSIONS_DIR, dirName);
if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) {
continue;
}
const { removed, manifestUpdated } = cleanMaterializedExtensionPlugin(extensionPath);
total += removed;
if (manifestUpdated) {
manifestsUpdated++;
}
}
}
console.log();
if (total === 0 && manifestsUpdated === 0) {
console.log("✅ No materialized files found. Plugins are already clean.");
+68
View File
@@ -459,6 +459,55 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
);
}
if (manifest.extenions !== undefined) {
errors.push(
`submission: plugins tagged with "canvas" must use "extensions" (found misspelled key "extenions") in "${manifestPath}"`,
);
}
if (manifest.extensions !== undefined && manifest.extensions !== "extensions") {
errors.push(
`submission: plugins tagged with "canvas" may omit "extensions", but if provided it must be "extensions" in "${manifestPath}"`,
);
}
const extensionContainerPath = joinRepoPath(pluginRoot, "extensions");
const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token);
if (extensionContainerResponse.kind === "notFound") {
errors.push(
`submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`,
);
} else if (extensionContainerResponse.kind === "apiError") {
warnings.push(
`submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
);
} else if (
!(
extensionContainerResponse.data?.type === "dir"
|| Array.isArray(extensionContainerResponse.data)
)
) {
errors.push(
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
);
}
const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs");
const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token);
if (extensionEntryResponse.kind === "notFound") {
errors.push(
`submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`,
);
} else if (extensionEntryResponse.kind === "apiError") {
warnings.push(
`submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
);
} else if (extensionEntryResponse.data?.type !== "file") {
errors.push(
`submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`,
);
}
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
const previewResponse = await fetchGitHubFile(repo, previewPath, releaseLocator, token);
if (previewResponse.kind === "notFound") {
@@ -580,11 +629,13 @@ function normalizeQualityGateResult(rawResult) {
vally_lint_status: "not_run",
smoke_status: "not_run",
version_match_status: "not_run",
canvas_structure_status: "not_run",
failure_class: "none",
summary: "",
vally_lint_output: "",
smoke_output: "",
version_match_output: "",
canvas_structure_output: "",
};
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
@@ -601,6 +652,7 @@ function buildQualityGatesCommentSection(qualityResult) {
const vallyState = qualityResult.vally_lint_status || "not_run";
const smokeState = qualityResult.smoke_status || "not_run";
const versionMatchState = qualityResult.version_match_status || "not_run";
const canvasStructureState = qualityResult.canvas_structure_status || "not_run";
const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._";
const sections = [
@@ -611,6 +663,7 @@ function buildQualityGatesCommentSection(qualityResult) {
`| vally lint | ${vallyState} |`,
`| install smoke test | ${smokeState} |`,
`| version match | ${versionMatchState} |`,
`| canvas structure | ${canvasStructureState} |`,
"",
summaryText,
];
@@ -660,6 +713,21 @@ function buildQualityGatesCommentSection(qualityResult) {
);
}
const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim();
if (canvasStructureOutput) {
sections.push(
"",
"<details>",
"<summary>Canvas structure output</summary>",
"",
"```text",
canvasStructureOutput,
"```",
"",
"</details>",
);
}
return sections.join("\n");
}
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runExternalPluginPrQualityGates(plugins) {
? "No changed external plugin entries were detected in plugins/external.json."
: checkedPlugins
.map((entry) =>
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, overall=${entry.quality.overall_status}`
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
)
.join("\n");
+166 -1
View File
@@ -8,6 +8,7 @@ import { spawnSync } from "child_process";
import { runLint, LintConsoleReporter } from "@microsoft/vally";
const MAX_OUTPUT_LENGTH = 12000;
const EXTERNAL_CANVAS_KEYWORD = "canvas";
const INFRA_ERROR_PATTERNS = [
/\b401\b/,
@@ -69,6 +70,12 @@ function normalizePluginPath(pluginPath) {
return normalized;
}
function hasCanvasKeyword(plugin) {
return (plugin?.keywords ?? []).some(
(keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD,
);
}
function resolveFetchSpec(pluginSource) {
if (pluginSource.sha) {
return pluginSource.sha;
@@ -467,6 +474,148 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) {
};
}
function checkPathExistsAtLocator(repoDir, locator, repoPath, expectedType) {
const result = runCommand("git", ["cat-file", "-e", `${locator}:${repoPath}`], { cwd: repoDir });
if (result.exitCode === 0) {
if (!expectedType) {
return { exists: true, output: "" };
}
const typeResult = runCommand("git", ["cat-file", "-t", `${locator}:${repoPath}`], { cwd: repoDir });
if (typeResult.exitCode !== 0) {
return {
exists: false,
output: `Unable to verify path "${repoPath}" type at "${locator}": ${typeResult.output}`,
};
}
const actualType = String(typeResult.stdout ?? "").trim();
if (actualType !== expectedType) {
return {
exists: false,
output: "",
kindMismatch: true,
actualType,
};
}
return { exists: true, output: "" };
}
const normalizedOutput = String(result.output ?? "").toLowerCase();
if (
normalizedOutput.includes("not a valid object name")
|| normalizedOutput.includes("path '")
|| normalizedOutput.includes("does not exist")
) {
return { exists: false, output: "" };
}
return {
exists: false,
output: `Unable to verify path "${repoPath}" at "${locator}": ${result.output}`,
};
}
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
if (!hasCanvasKeyword(plugin)) {
return {
status: "not_run",
output: "Canvas structure gate skipped because plugin is not tagged with \"canvas\".",
};
}
const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/");
const locators = [plugin?.source?.ref, plugin?.source?.sha]
.filter((value) => typeof value === "string" && value.trim().length > 0)
.map((value) => value.trim())
.filter((value, index, values) => values.indexOf(value) === index);
if (locators.length === 0) {
return {
status: "not_run",
output: "Canvas structure gate skipped because neither source.ref nor source.sha was provided.",
};
}
const extensionsDir = toPosixPath(normalizedPluginPath, "extensions");
const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs");
let hasFailure = false;
let hasInfraError = false;
const messages = [];
for (const locator of locators) {
if (locator !== primaryFetchSpec) {
const fetchResult = fetchLocatorIntoRepo(repoDir, locator);
if (fetchResult.status === "fail") {
hasFailure = true;
messages.push(`- ${locator}: ${fetchResult.output}`);
continue;
}
if (fetchResult.status === "infra_error") {
hasInfraError = true;
messages.push(`- ${locator}: ${fetchResult.output}`);
continue;
}
}
const extensionDirCheck = checkPathExistsAtLocator(repoDir, locator, extensionsDir, "tree");
if (extensionDirCheck.output) {
hasInfraError = true;
messages.push(`- ${locator}: ${extensionDirCheck.output}`);
continue;
}
if (!extensionDirCheck.exists) {
hasFailure = true;
if (extensionDirCheck.kindMismatch) {
messages.push(`- ${locator}: "${extensionsDir}" must be a directory.`);
} else {
messages.push(`- ${locator}: missing required canvas extension directory "${extensionsDir}".`);
}
continue;
}
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, locator, extensionEntryPoint, "blob");
if (extensionEntryCheck.output) {
hasInfraError = true;
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
continue;
}
if (!extensionEntryCheck.exists) {
hasFailure = true;
if (extensionEntryCheck.kindMismatch) {
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
} else {
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
}
continue;
}
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
}
if (hasInfraError) {
return {
status: "infra_error",
output: messages.join("\n"),
};
}
if (hasFailure) {
return {
status: "fail",
output: messages.join("\n"),
};
}
return {
status: "pass",
output: messages.join("\n"),
};
}
function toOverallStatus(states) {
if (states.includes("infra_error")) {
return "infra_error";
@@ -497,11 +646,13 @@ export async function runExternalPluginQualityGates(plugin) {
vally_lint_status: "not_run",
smoke_status: "not_run",
version_match_status: "not_run",
canvas_structure_status: "not_run",
failure_class: "none",
summary: "",
vally_lint_output: "",
smoke_output: "",
version_match_output: "",
canvas_structure_output: "",
};
try {
@@ -513,10 +664,14 @@ export async function runExternalPluginQualityGates(plugin) {
result.vally_lint_status = "fail";
result.smoke_status = "fail";
result.version_match_status = "fail";
result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run";
result.overall_status = "fail";
result.failure_class = "submitter_fixes";
result.summary = `Plugin path "${plugin.source?.path || "/"}" was not found in the submitted repository snapshot.`;
result.version_match_output = result.summary;
if (hasCanvasKeyword(plugin)) {
result.canvas_structure_output = result.summary;
}
return result;
}
@@ -524,6 +679,10 @@ export async function runExternalPluginQualityGates(plugin) {
result.version_match_status = versionMatchResult.status;
result.version_match_output = versionMatchResult.output;
const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec);
result.canvas_structure_status = canvasStructureResult.status;
result.canvas_structure_output = canvasStructureResult.output;
const vallyResult = await runVallyLintGate(pluginRoot);
result.vally_lint_status = vallyResult.status;
result.vally_lint_output = vallyResult.output;
@@ -532,12 +691,18 @@ export async function runExternalPluginQualityGates(plugin) {
result.smoke_status = smokeResult.status;
result.smoke_output = smokeResult.output;
result.overall_status = toOverallStatus([result.vally_lint_status, result.smoke_status, result.version_match_status]);
result.overall_status = toOverallStatus([
result.vally_lint_status,
result.smoke_status,
result.version_match_status,
result.canvas_structure_status,
]);
result.failure_class = toFailureClass(result.overall_status);
result.summary = [
`- vally lint: ${result.vally_lint_status}`,
`- install smoke test: ${result.smoke_status}`,
`- version match: ${result.version_match_status}`,
`- canvas structure: ${result.canvas_structure_status}`,
`- overall: ${result.overall_status}`,
].join("\n");
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import fs from "fs";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";
import { after, test } from "node:test";
import { runCanvasStructureGate } from "./external-plugin-quality-gates.mjs";
const tempDirs = [];
after(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function runGit(repoDir, ...args) {
const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" });
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stdout}\n${result.stderr}`);
}
return String(result.stdout ?? "").trim();
}
function createTempRepo() {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-plugin-quality-"));
tempDirs.push(repoDir);
runGit(repoDir, "init", "-q");
runGit(repoDir, "config", "user.name", "Copilot Test");
runGit(repoDir, "config", "user.email", "copilot@example.com");
return repoDir;
}
function commitAll(repoDir, message) {
runGit(repoDir, "add", "-A");
runGit(repoDir, "commit", "-m", message, "--quiet");
return runGit(repoDir, "rev-parse", "HEAD");
}
test("runCanvasStructureGate passes when extensions/extension.mjs exists", () => {
const repoDir = createTempRepo();
fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true });
fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n");
const sha = commitAll(repoDir, "Add canvas extension container");
const plugin = {
name: "canvas-plugin",
keywords: ["canvas"],
source: {
source: "github",
repo: "owner/repo",
sha,
},
};
const result = runCanvasStructureGate(repoDir, plugin, sha);
assert.equal(result.status, "pass");
assert.match(result.output, /found "extensions"/);
});
test("runCanvasStructureGate fails when extension entrypoint is only at repo root", () => {
const repoDir = createTempRepo();
fs.writeFileSync(path.join(repoDir, "extension.mjs"), "export default {};\n");
const sha = commitAll(repoDir, "Add root extension entrypoint");
const plugin = {
name: "canvas-plugin",
keywords: ["canvas"],
source: {
source: "github",
repo: "owner/repo",
sha,
},
};
const result = runCanvasStructureGate(repoDir, plugin, sha);
assert.equal(result.status, "fail");
assert.match(result.output, /missing required canvas extension directory "extensions"/);
});
test("runCanvasStructureGate fails when extension entrypoint path is a directory", () => {
const repoDir = createTempRepo();
fs.mkdirSync(path.join(repoDir, "extensions", "extension.mjs"), { recursive: true });
fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs", "placeholder.txt"), "not-a-module\n");
const sha = commitAll(repoDir, "Add invalid extension entrypoint directory");
const plugin = {
name: "canvas-plugin",
keywords: ["canvas"],
source: {
source: "github",
repo: "owner/repo",
sha,
},
};
const result = runCanvasStructureGate(repoDir, plugin, sha);
assert.equal(result.status, "fail");
assert.match(result.output, /"extensions\/extension\.mjs" must be a file/);
});
+92 -2
View File
@@ -2,9 +2,11 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
/**
* Recursively copy a directory.
@@ -22,6 +24,17 @@ function copyDirRecursive(src, dest) {
}
}
function copyEntryRecursive(srcPath, destPath) {
const stats = fs.statSync(srcPath);
if (stats.isDirectory()) {
copyDirRecursive(srcPath, destPath);
return;
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
}
/**
* Resolve a plugin-relative path to the repo-root source file.
*
@@ -45,6 +58,48 @@ function resolveSource(relPath) {
return null;
}
export function materializeExtensionPlugin(extensionPath) {
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
if (!fs.existsSync(pluginJsonPath)) {
return { copiedEntries: 0, manifestUpdated: false, skipped: true };
}
let metadata;
try {
metadata = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
} catch (err) {
throw new Error(`Failed to parse ${pluginJsonPath}: ${err.message}`);
}
const extensionContainerPath = path.join(extensionPath, "extensions");
fs.rmSync(extensionContainerPath, { recursive: true, force: true });
fs.mkdirSync(extensionContainerPath, { recursive: true });
let copiedEntries = 0;
for (const entry of fs.readdirSync(extensionPath, { withFileTypes: true })) {
if (entry.name === ".github" || entry.name === "extensions") {
continue;
}
copyEntryRecursive(
path.join(extensionPath, entry.name),
path.join(extensionContainerPath, entry.name)
);
copiedEntries++;
}
let manifestUpdated = false;
if (metadata.extensions !== "extensions") {
metadata.extensions = "extensions";
manifestUpdated = true;
}
if (manifestUpdated) {
fs.writeFileSync(pluginJsonPath, JSON.stringify(metadata, null, 2) + "\n", "utf8");
}
return { copiedEntries, manifestUpdated, skipped: false };
}
function materializePlugins() {
console.log("Materializing plugin files...\n");
@@ -61,6 +116,8 @@ function materializePlugins() {
let totalAgents = 0;
let totalSkills = 0;
let totalExtensions = 0;
let totalExtensionPlugins = 0;
let totalExtensionPluginEntries = 0;
let warnings = 0;
let errors = 0;
@@ -185,7 +242,36 @@ function materializePlugins() {
}
}
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`);
if (fs.existsSync(EXTENSIONS_DIR)) {
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const dirName of extensionDirs) {
const extensionPath = path.join(EXTENSIONS_DIR, dirName);
if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) {
continue;
}
try {
const result = materializeExtensionPlugin(extensionPath);
if (result.skipped) {
continue;
}
totalExtensionPlugins++;
totalExtensionPluginEntries += result.copiedEntries;
console.log(`${dirName}: materialized extension bundle into ./extensions (${result.copiedEntries} entries)`);
} catch (err) {
console.error(`Error: Failed to materialize extension plugin ${dirName}: ${err.message}`);
errors++;
}
}
}
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} plugin extension refs.`);
console.log(`Materialized ${totalExtensionPlugins} extension plugins (${totalExtensionPluginEntries} top-level entries).`);
if (warnings > 0) {
console.log(`${warnings} warning(s).`);
}
@@ -195,4 +281,8 @@ function materializePlugins() {
}
}
materializePlugins();
export { materializePlugins };
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
materializePlugins();
}
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import fs from "fs";
import os from "os";
import path from "path";
import { after, test } from "node:test";
import { materializeExtensionPlugin } from "./materialize-plugins.mjs";
const tempDirs = [];
after(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("materializeExtensionPlugin writes extension bundles to ./extensions and rewrites manifest", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "materialize-extension-plugin-"));
tempDirs.push(tempDir);
const pluginDir = path.join(tempDir, "extension-plugin");
fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true });
fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true });
fs.writeFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({
name: "test-extension-plugin",
description: "test plugin",
version: "1.0.0",
logo: "assets/preview.png",
extensions: ".",
}, null, 2));
fs.writeFileSync(path.join(pluginDir, "extension.mjs"), "export default {};\n");
fs.writeFileSync(path.join(pluginDir, "README.md"), "# test\n");
fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes");
const result = materializeExtensionPlugin(pluginDir);
assert.equal(result.skipped, false);
assert.equal(result.manifestUpdated, true);
assert.equal(result.copiedEntries, 3);
assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "extension.mjs")), true);
assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "assets", "preview.png")), true);
assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "README.md")), true);
assert.equal(fs.existsSync(path.join(pluginDir, "extensions", ".github")), false);
const pluginManifest = JSON.parse(
fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8")
);
assert.equal(pluginManifest.extensions, "extensions");
});
+7 -2
View File
@@ -305,9 +305,14 @@ function validateExtensionManifest(folderName) {
errors.push("x-awesome-copilot field must not be present (use convention-based logo instead)");
}
// Extension convention: extensions field must be "."
if (parsed.extenions !== undefined) {
errors.push('use "extensions" field (found misspelled key "extenions")');
}
// Extension convention: source manifests keep extensions at repository root.
// Materialization rewrites this to "extensions" on distribution branches.
if (parsed.extensions !== ".") {
errors.push('extensions field must be exactly "." (extension convention)');
errors.push('extensions field must be exactly "." in source manifests (extension convention)');
}
return { errors, plugin: parsedPlugin };
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "accessibility-kanban",
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
"version": "1.0.0",
"version": "1.0.1",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
@@ -15,5 +15,5 @@
"status-tracking"
],
"logo": "assets/preview.png",
"extensions": "."
"extensions": "extensions"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,728 @@
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXTENSION_NAME = "accessibility-kanban";
const STATE_FILE_PREFIX = "repository-issues-kanban-state";
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
const VALID_COLUMNS = new Set(COLUMNS);
const REFRESH_ISSUES_ERROR = "Unable to refresh issues right now. Please try again.";
let repoInfoCache = null;
let githubTokenCache;
let workspaceCwd = null;
// The canvas request context reports the active session's *working directory*
// (i.e. the repo checkout). Note this is different from `session.workspacePath`,
// which points at the session-state folder (~/.copilot/session-state/<id>) and is
// NOT a git repo — using it for git resolution is what caused the board to fail
// with "Unable to detect the current repository from this workspace."
function setWorkspaceCwd(dir) {
if (typeof dir !== "string" || !dir.trim() || dir === workspaceCwd) return;
workspaceCwd = dir;
repoInfoCache = null;
githubTokenCache = undefined;
}
function captureCwd(ctx) {
setWorkspaceCwd(ctx?.session?.workingDirectory);
}
function getWorkspaceCwd() {
return workspaceCwd || process.cwd();
}
// ─── Repo resolution ───
function runCommand(command, args, cwd = process.cwd()) {
try {
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
if (result.status === 0 && !result.error) {
return (result.stdout || "").trim();
}
} catch {
// Ignore and fall through to empty string.
}
return "";
}
function normalizeRepo(repo) {
if (typeof repo !== "string") return null;
const cleaned = repo
.trim()
.replace(/^https?:\/\/github\.com\//i, "")
.replace(/\.git$/i, "");
if (!/^[^/\s]+\/[^/\s]+$/.test(cleaned)) return null;
return cleaned;
}
function parseRepoFromRemoteUrl(remoteUrl) {
if (!remoteUrl) return null;
const cleaned = remoteUrl.trim().replace(/\.git$/i, "");
const sshMatch = cleaned.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/);
if (sshMatch) return sshMatch[1];
const httpMatch = cleaned.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/i);
if (httpMatch) return httpMatch[1];
const fallbackMatch = cleaned.match(/[:/]([^/:]+\/[^/:]+)$/);
return fallbackMatch ? fallbackMatch[1] : null;
}
function candidateCwds(preferredCwd) {
const candidates = [
preferredCwd,
workspaceCwd,
process.cwd(),
__dirname,
path.dirname(__dirname),
path.dirname(path.dirname(__dirname)),
path.dirname(path.dirname(path.dirname(__dirname))),
].filter(Boolean);
return [...new Set(candidates)];
}
function resolveRepoFromGit(cwd) {
const gitRoot = runCommand("git", ["rev-parse", "--show-toplevel"], cwd);
if (!gitRoot) return null;
const fromGh = normalizeRepo(runCommand("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], gitRoot));
if (fromGh) return fromGh;
const remoteUrl = runCommand("git", ["remote", "get-url", "origin"], gitRoot) || runCommand("git", ["config", "--get", "remote.origin.url"], gitRoot);
return normalizeRepo(parseRepoFromRemoteUrl(remoteUrl));
}
function resolveCurrentRepoInfo(cwd = getWorkspaceCwd()) {
const fromEnv = normalizeRepo(process.env.GITHUB_REPOSITORY || "");
if (fromEnv) return { repo: fromEnv, error: null };
for (const candidate of candidateCwds(cwd)) {
const repo = resolveRepoFromGit(candidate);
if (repo) return { repo, error: null };
}
return {
repo: "unknown/unknown",
error: "Unable to detect the current repository from this workspace.",
};
}
function getRepoInfo() {
const cwd = getWorkspaceCwd();
if (!repoInfoCache || repoInfoCache.cwd !== cwd) {
const resolved = resolveCurrentRepoInfo(cwd);
repoInfoCache = { ...resolved, cwd };
}
return repoInfoCache;
}
// ─── State persistence ───
function copilotHome() {
return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
}
function stateFileName(repo) {
const key = String(repo || "unknown-unknown")
.toLowerCase()
.replace(/[^\w.-]+/g, "-");
return `${STATE_FILE_PREFIX}-${key}.json`;
}
function getStatePath(repo) {
return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", stateFileName(repo));
}
function ensureStateDirectory(repo) {
fs.mkdirSync(path.dirname(getStatePath(repo)), { recursive: true });
}
function defaultState(repoInfo = getRepoInfo()) {
return {
repo: repoInfo.repo,
error: repoInfo.error,
updatedAt: new Date().toISOString(),
generation: Date.now(),
columns: COLUMNS,
availableLabels: [],
selectedLabels: [],
issues: [],
};
}
function normalizeLabelList(labels) {
const unique = new Set();
for (const label of Array.isArray(labels) ? labels : []) {
if (typeof label === "string" && label.trim()) unique.add(label.trim());
}
return [...unique];
}
function computeAvailableLabels(issues) {
const labels = new Set();
for (const issue of Array.isArray(issues) ? issues : []) {
for (const label of normalizeLabelList(issue.labels)) labels.add(label);
}
return [...labels].sort((a, b) => a.localeCompare(b));
}
function normalizeIssue(issue, repo, idx) {
if (!issue || !Number.isInteger(issue.number) || !issue.title) return null;
return {
number: issue.number,
title: issue.title,
url: issue.url || `https://github.com/${repo}/issues/${issue.number}`,
labels: normalizeLabelList(issue.labels),
column: VALID_COLUMNS.has(issue.column) ? issue.column : "backlog",
priority: issue.priority || "medium",
order: Number.isInteger(issue.order) ? issue.order : idx,
agentStatus: typeof issue.agentStatus === "string" ? issue.agentStatus : "",
agentActive: Boolean(issue.agentActive),
logs: Array.isArray(issue.logs) ? issue.logs : [],
};
}
function normalizeState(rawState, repoInfo = getRepoInfo()) {
const repo = repoInfo.repo;
const issues = Array.isArray(rawState?.issues)
? rawState.issues.map((issue, idx) => normalizeIssue(issue, repo, idx)).filter(Boolean)
: [];
const availableLabels = computeAvailableLabels(issues);
return {
repo,
error: repoInfo.error || (rawState?.error === REFRESH_ISSUES_ERROR ? REFRESH_ISSUES_ERROR : null),
updatedAt: rawState?.updatedAt || new Date().toISOString(),
generation: rawState?.generation || Date.now(),
columns: Array.isArray(rawState?.columns) && rawState.columns.length ? rawState.columns : COLUMNS,
availableLabels,
selectedLabels: normalizeLabelList(rawState?.selectedLabels).filter((label) => availableLabels.includes(label)),
issues,
};
}
function loadState(repo) {
try {
return JSON.parse(fs.readFileSync(getStatePath(repo), "utf8"));
} catch {
return null;
}
}
function saveState(state) {
ensureStateDirectory(state.repo);
fs.writeFileSync(
getStatePath(state.repo),
JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2),
);
}
function currentState() {
const repoInfo = getRepoInfo();
const loaded = loadState(repoInfo.repo);
const normalized = normalizeState(loaded || defaultState(repoInfo), repoInfo);
if (!loaded) saveState(normalized);
return normalized;
}
// ─── Issue operations ───
function moveIssue(issueNumber, column) {
if (!VALID_COLUMNS.has(column)) {
throw new CanvasError("invalid_column", `Column must be one of: ${COLUMNS.join(", ")}`);
}
const state = currentState();
const issue = state.issues.find((i) => i.number === issueNumber);
if (!issue) {
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
}
const prevColumn = issue.column;
issue.column = column;
issue.order = state.issues.filter((i) => i.column === column).length;
if (column === "done" || column === "backlog") {
issue.agentActive = false;
issue.agentStatus = column === "done" ? "Complete" : "";
}
saveState(state);
broadcast("state", state);
return { issue, prevColumn };
}
function updateIssueStatus(issueNumber, status, logEntry) {
const state = currentState();
const issue = state.issues.find((i) => i.number === issueNumber);
if (!issue) {
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
}
if (issue.column === "backlog") return issue;
if (status !== undefined) issue.agentStatus = status;
if (logEntry) {
if (!issue.logs) issue.logs = [];
issue.logs.push({ timestamp: new Date().toISOString(), message: logEntry });
}
issue.agentActive = true;
saveState(state);
broadcast("state", state);
return issue;
}
function clearAgentStatus(issueNumber) {
const state = currentState();
const issue = state.issues.find((i) => i.number === issueNumber);
if (!issue) return;
issue.agentActive = false;
saveState(state);
broadcast("state", state);
}
function replaceIssues(issues) {
const existing = currentState();
const existingByNumber = new Map(existing.issues.map((i) => [i.number, i]));
const nextIssues = (Array.isArray(issues) ? issues : [])
.filter((i) => i && Number.isInteger(i.number) && i.title)
.map((issue, idx) => {
const prev = existingByNumber.get(issue.number);
const labels = Array.isArray(issue.labels)
? issue.labels.map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean)
: [];
return {
number: issue.number,
title: issue.title,
url: issue.url || `https://github.com/${existing.repo}/issues/${issue.number}`,
labels: normalizeLabelList(labels),
column: VALID_COLUMNS.has(issue.column) ? issue.column : prev?.column || "backlog",
priority: issue.priority || prev?.priority || "medium",
order: Number.isInteger(issue.order) ? issue.order : prev?.order ?? idx,
agentStatus: prev?.agentStatus || "",
agentActive: Boolean(prev?.agentActive),
logs: Array.isArray(prev?.logs) ? prev.logs : [],
};
});
const availableLabels = computeAvailableLabels(nextIssues);
const next = {
...existing,
issues: nextIssues,
availableLabels,
selectedLabels: normalizeLabelList(existing.selectedLabels).filter((label) => availableLabels.includes(label)),
error: getRepoInfo().error,
};
saveState(next);
broadcast("state", next);
return next;
}
function setSelectedLabels(labels) {
const state = currentState();
state.selectedLabels = normalizeLabelList(labels).filter((label) => state.availableLabels.includes(label));
saveState(state);
broadcast("state", state);
return state;
}
function resetBoard() {
const state = currentState();
const reset = {
...state,
selectedLabels: [],
issues: state.issues.map((issue, idx) => ({
...issue,
column: "backlog",
order: idx,
agentStatus: "",
agentActive: false,
logs: [],
})),
};
saveState(reset);
broadcast("state", reset);
return reset;
}
// ─── GitHub issue sync ───
function resolveGitHubToken(cwd = getWorkspaceCwd()) {
if (githubTokenCache !== undefined) return githubTokenCache;
githubTokenCache = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || runCommand("gh", ["auth", "token"], cwd) || "";
return githubTokenCache;
}
function mapGitHubIssue(issue) {
return {
number: issue.number,
title: issue.title,
url: issue.html_url,
labels: (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean),
};
}
async function fetchOpenIssues(repo) {
if (!repo || repo === "unknown/unknown") {
throw new CanvasError("repo_unavailable", "Current repository could not be detected.");
}
const [owner, repoName] = repo.split("/");
const token = resolveGitHubToken();
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "repository-issues-kanban",
};
if (token) headers.Authorization = `token ${token}`;
const allIssues = [];
let page = 1;
while (page <= 10) {
const params = new URLSearchParams({
state: "open",
per_page: "100",
page: String(page),
});
const response = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues?${params}`, { headers });
if (!response.ok) {
const body = await response.text();
throw new CanvasError("github_api_error", `GitHub API request failed (${response.status}): ${body.slice(0, 200)}`);
}
const pageItems = await response.json();
const mapped = pageItems
.filter((item) => !item.pull_request)
.map(mapGitHubIssue);
allIssues.push(...mapped);
if (pageItems.length < 100) break;
page += 1;
}
return allIssues;
}
function mergeFetchedIssues(existingState, fetchedIssues) {
const existingByNumber = new Map(existingState.issues.map((issue) => [issue.number, issue]));
const mergedIssues = fetchedIssues.map((issue, idx) => {
const prev = existingByNumber.get(issue.number);
return {
number: issue.number,
title: issue.title,
url: issue.url || `https://github.com/${existingState.repo}/issues/${issue.number}`,
labels: normalizeLabelList(issue.labels),
column: VALID_COLUMNS.has(prev?.column) ? prev.column : "backlog",
priority: prev?.priority || "medium",
order: Number.isInteger(prev?.order) ? prev.order : idx,
agentStatus: prev?.agentStatus || "",
agentActive: Boolean(prev?.agentActive),
logs: Array.isArray(prev?.logs) ? prev.logs : [],
};
});
const availableLabels = computeAvailableLabels(mergedIssues);
return {
...existingState,
issues: mergedIssues,
availableLabels,
selectedLabels: normalizeLabelList(existingState.selectedLabels).filter((label) => availableLabels.includes(label)),
error: getRepoInfo().error,
};
}
async function refreshIssuesSafe() {
const state = currentState();
if (state.repo === "unknown/unknown") {
saveState(state);
broadcast("state", state);
return state;
}
try {
const fetchedIssues = await fetchOpenIssues(state.repo);
const merged = mergeFetchedIssues(state, fetchedIssues);
saveState(merged);
broadcast("state", merged);
return merged;
} catch (error) {
console.error("[accessibility-kanban] Failed to refresh issues", error);
const failed = {
...state,
error: REFRESH_ISSUES_ERROR,
};
saveState(failed);
broadcast("state", failed);
return failed;
}
}
// ─── SSE ───
const sseClients = new Set();
function broadcast(event, data) {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of sseClients) res.write(msg);
}
// ─── HTTP helpers ───
function readJson(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
req.on("error", reject);
});
}
function json(res, code, data) {
res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
// ─── HTTP server ───
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/events") {
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
res.write(`event: state\ndata: ${JSON.stringify(currentState())}\n\n`);
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
json(res, 200, await refreshIssuesSafe());
return;
}
if (req.method === "POST" && url.pathname === "/api/move") {
const input = await readJson(req);
const { issue, prevColumn } = moveIssue(input.issue_number, input.column);
if (input.column === "plan" && prevColumn !== "plan") {
const repo = currentState().repo;
session.send({
prompt: `The Repository Issues Kanban just moved issue #${issue.number} ("${issue.title}") in ${repo} into the Plan column. Start planning the implementation for this issue in a background agent. Read the GitHub issue details, analyze the repository, and produce a concrete implementation plan. Use the kanban_update_status tool to post progress and then move the issue to "ready" with kanban_move_issue when planning is complete.`,
});
}
json(res, 200, { issue, state: currentState() });
return;
}
if (req.method === "POST" && url.pathname === "/api/update-status") {
const input = await readJson(req);
const issue = updateIssueStatus(input.issue_number, input.status, input.log);
if (input.done) clearAgentStatus(input.issue_number);
json(res, 200, { issue, state: currentState() });
return;
}
if (req.method === "POST" && url.pathname === "/api/filters") {
const input = await readJson(req);
const state = setSelectedLabels(input.labels);
json(res, 200, state);
return;
}
if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) {
const num = parseInt(url.pathname.split("/").pop(), 10);
const state = currentState();
const issue = state.issues.find((i) => i.number === num);
if (!issue) {
json(res, 404, { error: "not found" });
return;
}
json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] });
return;
}
if (req.method === "POST" && url.pathname === "/api/reset") {
resetBoard();
json(res, 200, await refreshIssuesSafe());
return;
}
if (url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8"));
return;
}
res.writeHead(404);
res.end("Not found");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
function getPort() {
return server.address().port;
}
// ─── Canvas declaration ───
const canvas = createCanvas({
id: "accessibility-kanban",
displayName: "Repository Issues Kanban",
description: "Kanban board for triaging open issues from the current repository into backlog, plan, ready, implement, and done lanes.",
actions: [
{
name: "get_state",
description: "Get the current board state including open repository issues and selected label filters.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler(ctx) {
captureCwd(ctx);
return refreshIssuesSafe();
},
},
{
name: "move_issue",
description: "Move an issue to a different column on the kanban board.",
inputSchema: {
type: "object",
properties: {
issue_number: { type: "number", description: "GitHub issue number" },
column: { type: "string", enum: COLUMNS, description: "Target column" },
},
required: ["issue_number", "column"],
additionalProperties: false,
},
handler(ctx) {
captureCwd(ctx);
const { issue } = moveIssue(ctx.input.issue_number, ctx.input.column);
return { issue, state: currentState() };
},
},
{
name: "refresh_issues",
description: "Replace the board with issue data supplied by the agent.",
inputSchema: {
type: "object",
properties: {
issues: {
type: "array",
items: {
type: "object",
properties: {
number: { type: "number" },
title: { type: "string" },
url: { type: "string" },
labels: {
type: "array",
items: {
oneOf: [
{ type: "string" },
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
],
},
},
column: { type: "string", enum: COLUMNS },
priority: { type: "string" },
order: { type: "number" },
},
required: ["number", "title"],
additionalProperties: true,
},
},
},
required: ["issues"],
additionalProperties: false,
},
handler(ctx) {
captureCwd(ctx);
return replaceIssues(ctx.input.issues);
},
},
{
name: "set_filters",
description: "Set selected label filters (OR semantics).",
inputSchema: {
type: "object",
properties: {
labels: { type: "array", items: { type: "string" } },
},
required: ["labels"],
additionalProperties: false,
},
handler(ctx) {
captureCwd(ctx);
return setSelectedLabels(ctx.input.labels);
},
},
{
name: "reset_state",
description: "Reset all cards to backlog and clear label filters, then refresh from live repo issues.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler(ctx) {
captureCwd(ctx);
resetBoard();
return refreshIssuesSafe();
},
},
],
async open(ctx) {
captureCwd(ctx);
const state = await refreshIssuesSafe();
broadcast("state", state);
return {
url: `http://127.0.0.1:${getPort()}`,
title: "Repository Issues Kanban",
status: `${state.issues.length} open issues in ${state.repo}`,
};
},
});
// ─── Join session (tools + canvas) ───
const session = await joinSession({
canvases: [canvas],
tools: [
{
name: "kanban_move_issue",
description: "Move an issue on the repository issues kanban board to a new column (backlog, plan, ready, implement, done).",
parameters: {
type: "object",
properties: {
issue_number: { type: "number", description: "GitHub issue number" },
column: { type: "string", enum: COLUMNS, description: "Target column to move the issue to" },
},
required: ["issue_number", "column"],
},
handler: async (args) => {
const { issue } = moveIssue(args.issue_number, args.column);
return JSON.stringify({ moved: true, issue, state: currentState() });
},
},
{
name: "kanban_update_status",
description: "Update the agent status line and log on a kanban card while planning or implementing an issue.",
parameters: {
type: "object",
properties: {
issue_number: { type: "number", description: "GitHub issue number" },
status: { type: "string", description: "Short status text shown on the card." },
log: { type: "string", description: "Detailed log entry appended to the issue's agent log." },
done: { type: "boolean", description: "Set true to stop the active glow." },
},
required: ["issue_number", "status"],
},
handler: async (args) => {
const issue = updateIssueStatus(args.issue_number, args.status, args.log);
if (args.done) clearAgentStatus(args.issue_number);
return JSON.stringify({ updated: true, issue });
},
},
],
});
@@ -0,0 +1,18 @@
{
"name": "accessibility-kanban",
"version": "1.0.0",
"type": "module",
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
},
"description": "Users drag accessibility issues across kanban lanes to plan, track, and complete remediation work.",
"keywords": [
"accessibility",
"kanban-board",
"issue-triage",
"planning-workflow",
"status-tracking",
"github-issues"
]
}
@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Repository Issues Kanban</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #ffffff;
--outer: #f1f5f9;
--text: #111827;
--muted: #6b7280;
--meta: #94a3b8;
--border: #e5e7eb;
--coral: #ff7f50;
--azure: #0ea5e9;
--sage: #84cc16;
--violet: #a78bfa;
--radius: 6px;
--font: 'DM Sans', sans-serif;
--mono: 'IBM Plex Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--outer);
color: var(--text);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px;
}
.board-wrap {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
}
header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
header .breadcrumb {
font-size: 10px;
color: var(--meta);
font-weight: 400;
}
header .breadcrumb .sep {
margin: 0 5px;
color: var(--border);
}
header .breadcrumb .current {
color: var(--text);
font-weight: 600;
font-size: 13px;
}
header .spacer { flex: 1; }
header .reset-btn {
font-family: var(--mono);
font-size: 9px;
color: var(--meta);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 8px;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
header .reset-btn:hover {
border-color: var(--coral);
color: var(--coral);
}
header .label-filter-container {
display: flex;
align-items: center;
gap: 4px;
max-width: 50%;
overflow-x: auto;
padding: 2px 0;
}
.label-chip {
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
font-size: 9px;
font-family: var(--mono);
color: var(--meta);
background: transparent;
cursor: pointer;
white-space: nowrap;
}
.label-chip:hover {
border-color: var(--azure);
color: var(--azure);
}
.label-chip.selected {
border-color: var(--azure);
color: var(--azure);
background: rgba(14, 165, 233, 0.08);
}
.label-filter-empty {
font-family: var(--mono);
font-size: 9px;
color: var(--meta);
}
.error-banner {
margin: 8px 12px 0;
border: 1px solid rgba(255, 127, 80, 0.3);
background: rgba(255, 127, 80, 0.08);
color: #9a3412;
border-radius: 6px;
padding: 6px 8px;
font-size: 10px;
line-height: 1.4;
}
.board {
flex: 1;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
overflow: hidden;
}
.column {
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border);
border-top: 2px solid var(--border);
}
.column:last-child { border-right: none; }
.col-backlog { border-top-color: var(--meta); }
.col-plan { border-top-color: var(--azure); }
.col-ready { border-top-color: var(--sage); }
.col-implement { border-top-color: var(--coral); }
.col-done { border-top-color: var(--violet); }
.column-header {
padding: 10px 12px 8px;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.column-header h2 {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.column-header .count {
font-family: var(--mono);
font-size: 9px;
color: var(--meta);
margin-left: auto;
}
.card-list {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
cursor: grab;
transition: border-color 0.12s ease;
position: relative;
}
.card:hover {
border-color: var(--meta);
}
.card.dragging {
opacity: 0.35;
}
.card .issue-title {
font-size: 11px;
font-weight: 400;
line-height: 1.4;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card .issue-title .num {
font-family: var(--mono);
font-size: 10px;
color: var(--meta);
margin-right: 4px;
}
/* Agent status line */
.card .agent-status {
font-family: var(--mono);
font-size: 9px;
color: var(--azure);
margin-top: 5px;
display: flex;
align-items: center;
gap: 4px;
}
.card .agent-status .pulse {
width: 4px; height: 4px;
border-radius: 50%;
background: var(--azure);
animation: pulse-dot 1.5s ease-in-out infinite;
}
/* Glowing border for active agent work */
.card.agent-active {
border-color: rgba(14, 165, 233, 0.4);
box-shadow:
0 0 0 1px rgba(14, 165, 233, 0.15),
0 0 12px rgba(14, 165, 233, 0.1);
animation: glow-pulse 2.5s ease-in-out infinite;
}
@keyframes glow-pulse {
0%, 100% {
box-shadow:
0 0 0 1px rgba(14, 165, 233, 0.15),
0 0 12px rgba(14, 165, 233, 0.1);
}
50% {
box-shadow:
0 0 0 1px rgba(14, 165, 233, 0.3),
0 0 18px rgba(14, 165, 233, 0.15);
}
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Log button */
.card .log-btn {
position: absolute;
top: 6px;
right: 6px;
width: 16px; height: 16px;
border-radius: 3px;
border: none;
background: transparent;
color: var(--meta);
font-size: 10px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: background 0.12s;
}
.card .log-btn:hover {
background: rgba(14,165,233,0.08);
color: var(--azure);
}
.card.agent-active .log-btn { display: flex; }
.card.has-logs:hover .log-btn { display: flex; }
/* Drop target highlight */
.column.drop-target {
background: rgba(14,165,233,0.02);
}
/* Ghost (drag preview) */
.ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.85;
transform: rotate(1.5deg) scale(1.01);
box-shadow: 0 8px 20px rgba(0,0,0,0.12);
}
/* Log Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 10000;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg);
border-radius: 10px;
width: 90%;
max-width: 480px;
max-height: 65vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid var(--border);
}
.modal-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 12px;
font-weight: 600;
}
.modal-header button {
border: none;
background: none;
font-size: 14px;
cursor: pointer;
color: var(--muted);
padding: 2px 6px;
border-radius: 3px;
}
.modal-header button:hover { background: var(--border); }
.modal-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.log-entry {
display: flex;
gap: 10px;
padding: 5px 0;
border-bottom: 1px solid rgba(0,0,0,0.03);
}
.log-entry:last-child { border-bottom: none; }
.log-entry .ts {
font-family: var(--mono);
font-size: 9px;
color: var(--meta);
white-space: nowrap;
padding-top: 2px;
min-width: 48px;
}
.log-entry .msg {
font-size: 11px;
line-height: 1.5;
color: var(--text);
}
.empty-log {
font-size: 11px;
color: var(--meta);
text-align: center;
padding: 20px;
}
</style>
</head>
<body>
<div class="board-wrap">
<header>
<div class="breadcrumb">
<span id="repo-name">Current Repository</span><span class="sep">/</span><span class="current">Issues Kanban</span>
</div>
<span class="spacer"></span>
<div class="label-filter-container" id="label-filters"></div>
<button class="reset-btn" id="clear-filters-btn">Clear Labels</button>
<button class="reset-btn" id="reset-btn">Reset</button>
</header>
<div class="error-banner" id="error-banner" hidden></div>
<div class="board" id="board"></div>
</div>
<!-- Log Modal -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Agent Log</h3>
<button id="modal-close">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script>
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
const COL_LABELS = { backlog: "Backlog", plan: "Plan", ready: "Ready", implement: "Implement", done: "Done" };
let state = { repo: "", issues: [], availableLabels: [], selectedLabels: [], error: null };
let dragState = null;
// ─── Rendering ───
function issueMatchesFilter(issue) {
if (!state.selectedLabels || state.selectedLabels.length === 0) return true;
const labels = Array.isArray(issue.labels) ? issue.labels : [];
return labels.some((label) => state.selectedLabels.includes(label));
}
function updateHeader() {
const repoEl = document.getElementById("repo-name");
repoEl.textContent = state.repo || "Current Repository";
}
function updateErrorBanner() {
const banner = document.getElementById("error-banner");
const message = state.error;
if (message) {
banner.hidden = false;
banner.textContent = message;
return;
}
banner.hidden = true;
banner.textContent = "";
}
function renderLabelFilters() {
const host = document.getElementById("label-filters");
host.innerHTML = "";
const labels = Array.isArray(state.availableLabels) ? state.availableLabels : [];
if (labels.length === 0) {
host.innerHTML = '<span class="label-filter-empty">No labels</span>';
return;
}
labels.forEach((label) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "label-chip";
chip.textContent = label;
if (state.selectedLabels.includes(label)) chip.classList.add("selected");
chip.addEventListener("click", () => toggleLabel(label));
host.appendChild(chip);
});
}
function render() {
updateHeader();
updateErrorBanner();
renderLabelFilters();
const board = document.getElementById("board");
board.innerHTML = "";
COLUMNS.forEach((col) => {
const issues = state.issues
.filter((i) => i.column === col)
.filter(issueMatchesFilter)
.sort((a, b) => (a.order || 0) - (b.order || 0));
const colEl = document.createElement("div");
colEl.className = `column col-${col}`;
colEl.dataset.column = col;
colEl.innerHTML = `
<div class="column-header">
<h2>${COL_LABELS[col]}</h2>
<span class="count">${issues.length}</span>
</div>
<div class="card-list" data-column="${col}"></div>
`;
const list = colEl.querySelector(".card-list");
issues.forEach((issue) => {
const card = document.createElement("div");
const hasLogs = issue.logs && issue.logs.length > 0;
let cls = "card";
if (issue.agentActive) cls += " agent-active";
if (hasLogs) cls += " has-logs";
card.className = cls;
card.dataset.issueNumber = issue.number;
let statusHtml = "";
if (issue.agentStatus) {
statusHtml = `<div class="agent-status">${issue.agentActive ? '<span class="pulse"></span>' : ''}${escHtml(issue.agentStatus)}</div>`;
}
card.innerHTML = `
<button class="log-btn" title="View agent log">&#x25A3;</button>
<div class="issue-title"><span class="num">#${issue.number}</span>${escHtml(issue.title)}</div>
${statusHtml}
`;
// Log button click
const logBtn = card.querySelector(".log-btn");
if (logBtn) {
logBtn.addEventListener("click", (e) => {
e.stopPropagation();
openLogModal(issue);
});
}
// Pointer drag
card.addEventListener("pointerdown", (e) => startDrag(e, card, issue));
list.appendChild(card);
});
board.appendChild(colEl);
});
}
function escHtml(s) {
return String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ─── Log Modal ───
function openLogModal(issue) {
const overlay = document.getElementById("modal-overlay");
const title = document.getElementById("modal-title");
const body = document.getElementById("modal-body");
title.textContent = `#${issue.number} — Agent Log`;
if (!issue.logs || issue.logs.length === 0) {
body.innerHTML = '<div class="empty-log">No agent activity logged yet.</div>';
} else {
body.innerHTML = issue.logs.map((l) => `
<div class="log-entry">
<span class="ts">${formatTime(l.timestamp)}</span>
<span class="msg">${escHtml(l.message)}</span>
</div>
`).join("");
body.scrollTop = body.scrollHeight;
}
overlay.classList.add("open");
}
function formatTime(ts) {
try {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch { return ""; }
}
document.getElementById("modal-close").addEventListener("click", () => {
document.getElementById("modal-overlay").classList.remove("open");
});
document.getElementById("modal-overlay").addEventListener("click", (e) => {
if (e.target === e.currentTarget) {
e.currentTarget.classList.remove("open");
}
});
// ─── Pointer Drag & Drop ───
function startDrag(e, cardEl, issue) {
if (e.button !== 0) return;
e.preventDefault();
cardEl.setPointerCapture(e.pointerId);
const rect = cardEl.getBoundingClientRect();
const ghost = cardEl.cloneNode(true);
ghost.className = "card ghost";
ghost.style.width = rect.width + "px";
ghost.style.left = rect.left + "px";
ghost.style.top = rect.top + "px";
document.body.appendChild(ghost);
cardEl.classList.add("dragging");
dragState = {
issue,
ghost,
cardEl,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
pointerId: e.pointerId,
};
cardEl.addEventListener("pointermove", onDragMove);
cardEl.addEventListener("pointerup", onDragEnd);
}
function onDragMove(e) {
if (!dragState) return;
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + "px";
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + "px";
// Highlight drop target
document.querySelectorAll(".column").forEach((col) => {
const r = col.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right) {
col.classList.add("drop-target");
} else {
col.classList.remove("drop-target");
}
});
}
function onDragEnd(e) {
if (!dragState) return;
const { issue, ghost, cardEl } = dragState;
cardEl.classList.remove("dragging");
cardEl.releasePointerCapture(dragState.pointerId);
cardEl.removeEventListener("pointermove", onDragMove);
cardEl.removeEventListener("pointerup", onDragEnd);
ghost.remove();
document.querySelectorAll(".column.drop-target").forEach((col) => {
col.classList.remove("drop-target");
});
// Find target column
const targetCol = document.elementFromPoint(e.clientX, e.clientY)?.closest(".column");
if (targetCol && targetCol.dataset.column !== issue.column) {
moveIssue(issue.number, targetCol.dataset.column);
}
dragState = null;
}
// ─── API ───
async function moveIssue(issueNumber, column) {
try {
const resp = await fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issue_number: issueNumber, column }),
});
const data = await resp.json();
state = data.state;
render();
} catch (err) {
console.error("Move failed:", err);
}
}
async function setSelectedLabels(labels) {
try {
const resp = await fetch("/api/filters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ labels }),
});
state = await resp.json();
render();
} catch (err) {
console.error("Updating filters failed:", err);
}
}
function toggleLabel(label) {
const selected = new Set(state.selectedLabels || []);
if (selected.has(label)) selected.delete(label);
else selected.add(label);
setSelectedLabels([...selected]);
}
// ─── SSE ───
function connectSSE() {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
try {
state = JSON.parse(e.data);
render();
} catch {}
});
es.addEventListener("connected", () => {});
es.onerror = () => {
es.close();
setTimeout(connectSSE, 2000);
};
}
// ─── Init ───
async function init() {
try {
const resp = await fetch("/api/state");
state = await resp.json();
} catch {}
render();
connectSSE();
document.getElementById("reset-btn").addEventListener("click", async () => {
if (!confirm("Reset all issues to backlog?")) return;
try {
const resp = await fetch("/api/reset", { method: "POST" });
state = await resp.json();
render();
} catch (err) {
console.error("Reset failed:", err);
}
});
document.getElementById("clear-filters-btn").addEventListener("click", () => {
setSelectedLabels([]);
});
}
init();
</script>
</body>
</html>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "apng-studio",
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
"version": "1.0.0",
"version": "1.0.1",
"author": {
"name": "Andrea Griffiths",
"url": "https://github.com/AndreaGriffiths11"
@@ -15,5 +15,5 @@
"interactive-canvas"
],
"logo": "assets/preview.png",
"extensions": "."
"extensions": "extensions"
}
@@ -0,0 +1,11 @@
# Runtime user data — per-project frames and exports the extension writes
# under its own directory at runtime. This is local state, not source.
artifacts/
# Exported animations
*.apng
# Node / editor / OS cruft
node_modules/
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 octobooth-1
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,97 @@
# APNG Studio
An interactive [GitHub Copilot app](https://github.com/features/ai/github-app) **canvas extension** for building [Animated PNG (APNG)](https://wiki.mozilla.org/APNG_Specification) files from frames — draw or upload frames, tune the full practical APNG spec surface, preview live, and export an animated `.png`.
The canvas renders in a side panel; the agent can also drive it through callable actions.
<p align="center">
<img src="assets/demo.png" alt="An animated walkthrough of the APNG Studio canvas building an animation from frames" width="640" />
</p>
<p align="center"><em>APNG Studio in action — an animated PNG built with the canvas itself.</em></p>
## Background
APNG Studio started as a hallway conversation. During a demo shift at the WeAreDevelopers Congress I got talking with [Jeff](https://github.com/GekkeBoyJeff) about APNG (animated PNG) versus GIF: APNG keeps real alpha and full color where GIF can't. We wanted an easy way to actually build one, so we made this small canvas wrapper for creating APNGs.
## Features
- **Frames** — upload images or draw them on a builtin canvas (pen/eraser, fill, onionskin, "start from last frame"). Reorder, duplicate, and delete frames.
- **Perframe timing** — set the delay as an exact `numerator / denominator` fraction with a live `= N ms · N fps` readout.
- **Perframe compositing** — `dispose_op` (None / Background / Previous) and `blend_op` (Source / Over) dropdowns, straight from the APNG spec.
- **Apply to all** — set every frame's delay (ms), snap to an exact frame rate (fps), or apply dispose + blend in one click.
- **Loop count** — `0` = infinite, or a fixed number of plays.
- **Hidden first frame** — mark frame 1 as a static fallback: shown by nonAPNG viewers, excluded from the animation loop (encoded as a default image with no leading `fcTL`, `num_frames = N1`).
- **Live preview** — a real animated PNG is assembled on every change and served from `/preview.png`; a **Reload** button resyncs state and rebuilds the preview.
- **Send to phone** — a **Send to phone** button opens a QR code; scan it with your phone camera (same WiFi) to open the live animation in your phone's browser and save it. Served readonly from a shortlived, tokengated LAN endpoint that shuts itself down after 10 minutes.
- **Export** — writes a valid animated `.png` (APNG) to disk and returns its path. The `.png` extension keeps the file bytecompatible with every PNG viewer: APNGaware ones (browsers, macOS Quick Look) animate it, others show the first frame as a static fallback.
## Install
### From GitHub Copilot (recommended)
Ask Copilot to install the committed extension URL:
```text
Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/apng-studio
```
You can also copy the folder into one of these locations:
- **User** — `~/.copilot/extensions/apng-studio/`, available in every project.
- **Project** — `.github/extensions/apng-studio/` inside a repo, committed and shared with your team.
Reload extensions in the app, then open the `apng-studio` canvas.
### Manual
Copy the source files into one of the extension directories above, keeping the layout:
```
apng-studio/
├── extension.mjs # entry point (required name)
├── apng.mjs # APNG codec + RGBA→PNG encoder
├── qr.mjs # dependency-free QR encoder (Send to phone)
└── web/ # canvas iframe renderer
├── index.html
├── app.js
└── styles.css
```
Then reload extensions. The `@github/copilot-sdk` import is resolved by the host — **do not** add a `package.json` or `node_modules` for it.
## Open the canvas
Once installed, open the **APNG Studio** canvas from Copilot. Optional open input:
| field | type | description |
| ----------- | ------ | ---------------------------------------------- |
| `projectId` | string | Animation project id (defaults to `default`). |
| `name` | string | Optional display name for the animation. |
Each project's frames persist on disk under `artifacts/<projectId>/`, so they survive reloads and are shared between every open panel and the agent actions. That folder is local user data and is **gitignored**.
## Agent actions
The extension exposes these callable actions on the `apng-studio` canvas:
| action | what it does |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `get_state` | Return project settings + perframe timing/compositing and total duration. |
| `set_settings` | Update `width`/`height` (only with 0 frames), `loops`, and `hiddenFirst`. |
| `add_color_frame` | Append a solidcolor frame; accepts `delayNum`/`delayDen`/`disposeOp`/`blendOp`. |
| `set_frame` | Update one frame (`frameId`) or all (`all: true`): timing via `delayMs`/`fps`/`delayNum`+`delayDen`, plus `disposeOp`/`blendOp`. |
| `clear_frames` | Remove every frame. |
| `export` | Assemble and write the animated `.png` (APNG) to disk; returns the absolute path. |
All actions accept an optional `projectId` to target a specific animation.
## How it works
- **`extension.mjs`** — one loopback HTTP server per open canvas instance serves the renderer, JSON state, perframe PNGs, the live `/preview.png`, and mutation endpoints. ServerSent Events (`/events`) push a `changed` signal so every open panel and the preview stay in sync. **Send to phone** spins up a separate, readonly LAN server that serves only a landing page and the preview image, gated by a shortlived random token and torn down on expiry.
- **`apng.mjs`** — assembles the APNG chunk stream (`IHDR` / `acTL` / `fcTL` / `IDAT` / `fdAT` / `IEND`) with contiguous sequence numbers, plus a minimal RGBA→PNG encoder.
- **`qr.mjs`** — a small, dependencyfree QR encoder (byte mode, errorcorrection level M) used to render the **Send to phone** code. The QR is drawn into a PNG with the `apng.mjs` encoder.
- **`web/`** — the iframe UI. It talks to its server over plain HTTP; there is no privileged host bridge.
## License
[MIT](./LICENSE)
+310
View File
@@ -0,0 +1,310 @@
// APNG codec helpers (Node-side).
//
// - assembleApng(): repackages a list of already-encoded PNG frames into a
// single Animated PNG, following https://wiki.mozilla.org/APNG_Specification.
// Because every frame is already a valid PNG sharing one IHDR, we only need
// to lift each frame's IDAT stream into the default image (frame 0) or into
// `fdAT` chunks (later frames), wrapped by `acTL`/`fcTL` control chunks with
// freshly computed CRC-32s. No re-compression required.
// - encodeRgbaPng(): minimal RGBA8 PNG encoder used for agent-generated frames.
import { deflateSync } from "node:zlib";
const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
function concat(chunks) {
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
function readU32(bytes, off) {
return (
((bytes[off] << 24) |
(bytes[off + 1] << 16) |
(bytes[off + 2] << 8) |
bytes[off + 3]) >>>
0
);
}
// Build a PNG chunk: [length][type][data][crc], CRC over type+data.
function chunk(type, data) {
const len = data.length;
const out = new Uint8Array(12 + len);
const view = new DataView(out.buffer);
view.setUint32(0, len);
out[4] = type.charCodeAt(0);
out[5] = type.charCodeAt(1);
out[6] = type.charCodeAt(2);
out[7] = type.charCodeAt(3);
out.set(data, 8);
view.setUint32(8 + len, crc32(out.subarray(4, 8 + len)));
return out;
}
// Parse the pieces of a PNG we care about: its IHDR data and the concatenated
// IDAT stream. Ancillary/color chunks are intentionally dropped — every frame
// shares a uniform RGBA8 IHDR so they are not needed.
function parsePng(bytes) {
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) {
throw new Error("Not a PNG (bad signature)");
}
}
let off = 8;
let ihdrData = null;
let width = 0;
let height = 0;
const idatParts = [];
while (off + 8 <= bytes.length) {
const len = readU32(bytes, off);
const type = String.fromCharCode(
bytes[off + 4],
bytes[off + 5],
bytes[off + 6],
bytes[off + 7]
);
const dataStart = off + 8;
const data = bytes.subarray(dataStart, dataStart + len);
if (type === "IHDR") {
ihdrData = data.slice();
width = readU32(data, 0);
height = readU32(data, 4);
} else if (type === "IDAT") {
idatParts.push(data.slice());
} else if (type === "IEND") {
break;
}
off = dataStart + len + 4; // skip data + CRC
}
if (!ihdrData) throw new Error("PNG missing IHDR");
if (idatParts.length === 0) throw new Error("PNG missing IDAT");
return { ihdrData, width, height, idat: concat(idatParts) };
}
function acTLChunk(numFrames, numPlays) {
const data = new Uint8Array(8);
const view = new DataView(data.buffer);
view.setUint32(0, numFrames >>> 0);
view.setUint32(4, numPlays >>> 0);
return chunk("acTL", data);
}
// dispose_op: 0 = NONE (leave as-is), 1 = BACKGROUND (clear region to transparent
// black), 2 = PREVIOUS (revert region to what it was before this frame).
// blend_op: 0 = SOURCE (overwrite region, alpha included), 1 = OVER (alpha-blend
// this frame over the current canvas contents).
function fcTLChunk(sequence, width, height, params) {
const {
delayNum = 100,
delayDen = 1000,
disposeOp = 0,
blendOp = 0,
xOffset = 0,
yOffset = 0,
} = params || {};
const data = new Uint8Array(26);
const view = new DataView(data.buffer);
view.setUint32(0, sequence >>> 0); // sequence_number
view.setUint32(4, width >>> 0); // width
view.setUint32(8, height >>> 0); // height
view.setUint32(12, xOffset >>> 0); // x_offset
view.setUint32(16, yOffset >>> 0); // y_offset
view.setUint16(20, delayNum & 0xffff); // delay_num
view.setUint16(22, delayDen & 0xffff); // delay_den
data[24] = disposeOp & 0xff; // dispose_op
data[25] = blendOp & 0xff; // blend_op
return chunk("fcTL", data);
}
function fdATChunk(sequence, idat) {
const data = new Uint8Array(4 + idat.length);
new DataView(data.buffer).setUint32(0, sequence >>> 0);
data.set(idat, 4);
return chunk("fdAT", data);
}
const clampU16 = (n, dflt = 0) => {
const v = Math.round(Number(n));
return Number.isFinite(v) ? Math.max(0, Math.min(0xffff, v)) : dflt;
};
const clampDen = (n) => {
const v = Math.round(Number(n));
return Number.isFinite(v) && v >= 1 ? Math.min(0xffff, v) : 1000;
};
const clampOp = (n, hi) => {
const v = Math.round(Number(n));
return Number.isFinite(v) ? Math.max(0, Math.min(hi, v)) : 0;
};
// Normalize a caller-supplied frame descriptor into the exact fcTL fields.
// Timing accepts either delayNum/delayDen (exact) or a delayMs shorthand
// (treated as delayNum ms over a 1000 denominator).
function frameParams(f) {
let delayNum;
let delayDen;
if (f.delayNum != null) {
delayNum = clampU16(f.delayNum, 100);
delayDen = clampDen(f.delayDen);
} else {
delayNum = clampU16(f.delayMs, 100);
delayDen = 1000;
}
return {
delayNum,
delayDen,
disposeOp: clampOp(f.disposeOp, 2),
blendOp: clampOp(f.blendOp, 1),
};
}
// The APNG spec forbids APNG_DISPOSE_OP_PREVIOUS on the first fcTL (decoders
// must treat it as BACKGROUND). Normalize it for whichever frame is composited
// first so our output is well-defined instead of relying on decoder leniency.
function firstFrameParams(f) {
const p = frameParams(f);
if (p.disposeOp === 2) p.disposeOp = 1;
return p;
}
/**
* Assemble an APNG from a list of PNG frames.
*
* @param {Array<{png: Uint8Array, delayMs?: number, delayNum?: number, delayDen?: number, disposeOp?: number, blendOp?: number}>} frames
* @param {{loops?: number, hiddenFirst?: boolean}} [options]
* loops: 0 = infinite. hiddenFirst: when true (and >=2 frames) the first frame
* becomes the static default image shown by non-APNG viewers and is NOT part
* of the animation; frames 2..N make up the loop.
* @returns {Uint8Array} APNG bytes.
*/
export function assembleApng(frames, options = {}) {
if (!Array.isArray(frames) || frames.length === 0) {
throw new Error("assembleApng requires at least one frame");
}
const loops = Math.max(0, Math.round(Number(options.loops) || 0));
const hiddenFirst = !!options.hiddenFirst && frames.length >= 2;
const parsed = frames.map((f) => parsePng(f.png));
const width = parsed[0].width;
const height = parsed[0].height;
for (const p of parsed) {
if (p.width !== width || p.height !== height) {
throw new Error(
`All frames must share dimensions (${width}x${height}); found ${p.width}x${p.height}`
);
}
}
const parts = [PNG_SIGNATURE, chunk("IHDR", parsed[0].ihdrData)];
const numFrames = hiddenFirst ? parsed.length - 1 : parsed.length;
parts.push(acTLChunk(numFrames, loops));
let seq = 0;
if (hiddenFirst) {
// Default image = frame 0, with no fcTL, so it is not animated.
parts.push(chunk("IDAT", parsed[0].idat));
for (let i = 1; i < parsed.length; i++) {
const params = i === 1 ? firstFrameParams(frames[i]) : frameParams(frames[i]);
parts.push(fcTLChunk(seq++, width, height, params));
parts.push(fdATChunk(seq++, parsed[i].idat));
}
} else {
// Frame 0 = default image AND first animation frame: fcTL then IDAT.
parts.push(fcTLChunk(seq++, width, height, firstFrameParams(frames[0])));
parts.push(chunk("IDAT", parsed[0].idat));
// Remaining frames: fcTL then fdAT (each carries a sequence number).
for (let i = 1; i < parsed.length; i++) {
parts.push(fcTLChunk(seq++, width, height, frameParams(frames[i])));
parts.push(fdATChunk(seq++, parsed[i].idat));
}
}
parts.push(chunk("IEND", new Uint8Array(0)));
return concat(parts);
}
/**
* Encode an 8-bit RGBA pixel buffer into a (non-animated) PNG.
*
* @param {number} width
* @param {number} height
* @param {Uint8Array} rgba length must be width*height*4
* @returns {Uint8Array} PNG bytes.
*/
export function encodeRgbaPng(width, height, rgba) {
if (rgba.length !== width * height * 4) {
throw new Error("rgba length does not match width*height*4");
}
const ihdr = new Uint8Array(13);
const view = new DataView(ihdr.buffer);
view.setUint32(0, width >>> 0);
view.setUint32(4, height >>> 0);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter method
ihdr[12] = 0; // interlace
// Filtered raw scanlines: one leading filter byte (0 = None) per row.
const stride = width * 4;
const raw = new Uint8Array((stride + 1) * height);
for (let y = 0; y < height; y++) {
const src = y * stride;
const dst = y * (stride + 1);
raw[dst] = 0;
raw.set(rgba.subarray(src, src + stride), dst + 1);
}
const compressed = deflateSync(raw, { level: 9 });
return concat([
PNG_SIGNATURE,
chunk("IHDR", ihdr),
chunk("IDAT", new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length)),
chunk("IEND", new Uint8Array(0)),
]);
}
/**
* Convenience: encode a solid-color frame as a PNG.
* @param {number} width
* @param {number} height
* @param {{r:number,g:number,b:number,a:number}} color 0-255 components
*/
export function solidColorPng(width, height, color) {
const rgba = new Uint8Array(width * height * 4);
const { r, g, b, a } = color;
for (let i = 0; i < rgba.length; i += 4) {
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = a;
}
return encodeRgbaPng(width, height, rgba);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -0,0 +1,4 @@
{
"name": "apng-studio",
"version": 1
}
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
// Minimal, dependency-free QR Code encoder (byte mode, EC level M, versions
// 1-10). Enough to encode a short LAN URL for the "Send to phone" feature.
//
// Returns a square matrix of 0/1 modules. Rendering to PNG is done by the
// caller via the RGBA->PNG encoder in apng.mjs, so this file has no I/O.
//
// Reference: ISO/IEC 18004. Verified module-for-module against the python
// `qrcode` reference encoder (see eng verification) for forced masks 0-7.
// ---- Galois field GF(256), primitive polynomial 0x11d --------------------
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
// Reed-Solomon generator polynomial of the given degree.
function rsGenerator(degree) {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= gfMul(poly[j], EXP[i]);
next[j + 1] ^= poly[j];
}
poly = next;
}
return poly;
}
function rsEncode(data, ecLen) {
const gen = rsGenerator(ecLen); // constant-first; gen[ecLen] is the leading 1
const res = new Array(ecLen).fill(0);
for (const byte of data) {
const factor = byte ^ res[0];
res.shift();
res.push(0);
// Use the non-leading generator coefficients in descending-degree order.
for (let i = 0; i < ecLen; i++) res[i] ^= gfMul(gen[ecLen - 1 - i], factor);
}
return res;
}
// ---- Version tables (EC level M) ----------------------------------------
// [ecPerBlock, [[blockCount, dataCodewordsPerBlock], ...]]
const EC_BLOCKS_M = {
1: [10, [[1, 16]]],
2: [16, [[1, 28]]],
3: [26, [[1, 44]]],
4: [18, [[2, 32]]],
5: [24, [[2, 43]]],
6: [16, [[4, 27]]],
7: [18, [[4, 31]]],
8: [22, [[2, 38], [2, 39]]],
9: [22, [[3, 36], [2, 37]]],
10: [26, [[4, 43], [1, 44]]],
};
const ALIGN_POS = {
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
};
const totalDataCodewords = (v) =>
EC_BLOCKS_M[v][1].reduce((sum, [count, dc]) => sum + count * dc, 0);
const charCountBits = (v) => (v <= 9 ? 8 : 16);
function chooseVersion(dataLen) {
for (let v = 1; v <= 10; v++) {
const capacityBits = totalDataCodewords(v) * 8;
const needed = 4 + charCountBits(v) + dataLen * 8;
if (needed <= capacityBits) return v;
}
throw new Error("Data too long for QR versions 1-10 (byte mode, EC M)");
}
// ---- Bit buffer ----------------------------------------------------------
class BitBuffer {
constructor() {
this.bits = [];
}
put(value, length) {
for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1);
}
get length() {
return this.bits.length;
}
}
function buildCodewords(bytes, version) {
const buf = new BitBuffer();
buf.put(0b0100, 4); // byte mode
buf.put(bytes.length, charCountBits(version));
for (const b of bytes) buf.put(b, 8);
const capacityBits = totalDataCodewords(version) * 8;
// Terminator (up to 4 zero bits).
const term = Math.min(4, capacityBits - buf.length);
buf.put(0, term);
// Pad to a byte boundary.
while (buf.length % 8 !== 0) buf.bits.push(0);
// Pad bytes.
const padBytes = [0xec, 0x11];
let pi = 0;
while (buf.length < capacityBits) {
buf.put(padBytes[pi++ % 2], 8);
}
// Pack bits into data codewords.
const data = [];
for (let i = 0; i < buf.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | buf.bits[i + j];
data.push(byte);
}
// Split into blocks, compute EC, then interleave.
const [ecPerBlock, groups] = EC_BLOCKS_M[version];
const dataBlocks = [];
const ecBlocks = [];
let offset = 0;
for (const [count, dcPerBlock] of groups) {
for (let b = 0; b < count; b++) {
const block = data.slice(offset, offset + dcPerBlock);
offset += dcPerBlock;
dataBlocks.push(block);
ecBlocks.push(rsEncode(block, ecPerBlock));
}
}
const result = [];
const maxData = Math.max(...dataBlocks.map((b) => b.length));
for (let i = 0; i < maxData; i++) {
for (const block of dataBlocks) if (i < block.length) result.push(block[i]);
}
for (let i = 0; i < ecPerBlock; i++) {
for (const block of ecBlocks) result.push(block[i]);
}
return result;
}
// ---- Matrix construction -------------------------------------------------
function makeBaseMatrix(size) {
const m = Array.from({ length: size }, () => new Array(size).fill(null));
return m;
}
function placeFinder(m, r, c) {
for (let i = -1; i <= 7; i++) {
for (let j = -1; j <= 7; j++) {
const rr = r + i;
const cc = c + j;
if (rr < 0 || cc < 0 || rr >= m.length || cc >= m.length) continue;
const inRing =
i >= 0 && i <= 6 && j >= 0 && j <= 6 &&
(i === 0 || i === 6 || j === 0 || j === 6);
const inCore = i >= 2 && i <= 4 && j >= 2 && j <= 4;
m[rr][cc] = inRing || inCore ? 1 : 0;
}
}
}
function placeAlignment(m, version) {
const pos = ALIGN_POS[version];
for (const r of pos) {
for (const c of pos) {
// Skip the three finder corners.
if ((r === 6 && c === 6) || (r === 6 && c === m.length - 7) || (r === m.length - 7 && c === 6)) continue;
if (m[r][c] !== null) continue;
for (let i = -2; i <= 2; i++) {
for (let j = -2; j <= 2; j++) {
const ring = Math.max(Math.abs(i), Math.abs(j));
m[r + i][c + j] = ring === 1 ? 0 : 1;
}
}
}
}
}
function reserveFormat(m) {
const size = m.length;
// Marks format/version areas as reserved (use a sentinel we overwrite later).
// Handled implicitly: we set them during placement by skipping null-only.
return size;
}
const FORMAT_MASK = 0x5412;
function bchFormat(data5) {
let d = data5 << 10;
const g = 0b10100110111;
for (let i = 4; i >= 0; i--) {
if ((d >> (i + 10)) & 1) d ^= g << i;
}
return ((data5 << 10) | d) ^ FORMAT_MASK;
}
function bchVersion(version) {
let d = version << 12;
const g = 0b1111100100101;
for (let i = 5; i >= 0; i--) {
if ((d >> (i + 12)) & 1) d ^= g << i;
}
return (version << 12) | d;
}
const MASKS = [
(r, c) => (r + c) % 2 === 0,
(r, c) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
function isFunctionModule(reserved, r, c) {
return reserved[r][c];
}
function buildReserved(size, version) {
const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
const mark = (r, c) => {
if (r >= 0 && c >= 0 && r < size && c < size) reserved[r][c] = true;
};
// Finders + separators.
for (const [br, bc] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) mark(br + i, bc + j);
}
// Timing.
for (let i = 0; i < size; i++) {
mark(6, i);
mark(i, 6);
}
// Alignment.
const pos = ALIGN_POS[version];
for (const r of pos) for (const c of pos) {
if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue;
for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) mark(r + i, c + j);
}
// Format info areas.
for (let i = 0; i < 9; i++) {
mark(8, i);
mark(i, 8);
}
for (let i = 0; i < 8; i++) {
mark(8, size - 1 - i);
mark(size - 1 - i, 8);
}
mark(size - 8, 8); // dark module
// Version info (v >= 7).
if (version >= 7) {
for (let i = 0; i < 6; i++) for (let j = 0; j < 3; j++) {
mark(i, size - 11 + j);
mark(size - 11 + j, i);
}
}
return reserved;
}
function placeTiming(m) {
const size = m.length;
for (let i = 0; i < size; i++) {
if (m[6][i] === null) m[6][i] = i % 2 === 0 ? 1 : 0;
if (m[i][6] === null) m[i][6] = i % 2 === 0 ? 1 : 0;
}
}
function placeData(m, reserved, codewords) {
const size = m.length;
const bits = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1);
let idx = 0;
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) col--; // skip vertical timing column
for (let n = 0; n < size; n++) {
const row = upward ? size - 1 - n : n;
for (let k = 0; k < 2; k++) {
const c = col - k;
if (reserved[row][c]) continue;
m[row][c] = idx < bits.length ? bits[idx++] : 0;
}
}
upward = !upward;
}
}
function applyMask(m, reserved, maskFn) {
const out = m.map((row) => row.slice());
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m.length; c++) {
if (reserved[r][c]) continue;
if (maskFn(r, c)) out[r][c] ^= 1;
}
}
return out;
}
function placeFormatBits(m, maskIndex) {
const size = m.length;
// EC level M = 0b00. Format data = (ecBits << 3) | maskIndex.
const format = bchFormat((0b00 << 3) | maskIndex);
const bits = [];
for (let i = 14; i >= 0; i--) bits.push((format >> i) & 1);
// Around top-left finder.
const coords1 = [
[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
[7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8],
];
coords1.forEach(([r, c], i) => (m[r][c] = bits[i]));
// Split across top-right and bottom-left.
const coords2 = [
[size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8],
[size - 5, 8], [size - 6, 8], [size - 7, 8],
[8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5],
[8, size - 4], [8, size - 3], [8, size - 2], [8, size - 1],
];
coords2.forEach(([r, c], i) => (m[r][c] = bits[i]));
m[size - 8][8] = 1; // dark module
}
function placeVersionBits(m, version) {
if (version < 7) return;
const size = m.length;
const v = bchVersion(version);
const bits = [];
for (let i = 0; i <= 17; i++) bits.push((v >> i) & 1); // least-significant bit first
let idx = 0;
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 3; j++) {
const b = bits[idx++];
m[i][size - 11 + j] = b;
m[size - 11 + j][i] = b;
}
}
}
// Penalty scoring for mask selection (ISO 18004 rules 1-4).
function penalty(m) {
const size = m.length;
let score = 0;
// Rule 1: runs of 5+ same-color in rows/cols.
for (let r = 0; r < size; r++) {
for (const line of [m[r], m.map((row) => row[r])]) {
let run = 1;
for (let c = 1; c < size; c++) {
if (line[c] === line[c - 1]) {
run++;
if (run === 5) score += 3;
else if (run > 5) score += 1;
} else run = 1;
}
}
}
// Rule 2: 2x2 blocks.
for (let r = 0; r < size - 1; r++) {
for (let c = 0; c < size - 1; c++) {
const v = m[r][c];
if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
}
}
// Rule 3: finder-like patterns.
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
const matchAt = (line, i, pat) => pat.every((p, k) => line[i + k] === p);
for (let r = 0; r < size; r++) {
const rowLine = m[r];
const colLine = m.map((row) => row[r]);
for (let c = 0; c <= size - 11; c++) {
if (matchAt(rowLine, c, pat1) || matchAt(rowLine, c, pat2)) score += 40;
if (matchAt(colLine, c, pat1) || matchAt(colLine, c, pat2)) score += 40;
}
}
// Rule 4: dark/light balance.
let dark = 0;
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) dark += m[r][c];
const percent = (dark * 100) / (size * size);
const prev = Math.floor(percent / 5) * 5;
const next = prev + 5;
score += Math.min(Math.abs(prev - 50), Math.abs(next - 50)) / 5 * 10;
return score;
}
/**
* Encode a string into a QR matrix (array of rows of 0/1).
* @param {string} text
* @param {{forceMask?: number}} [opts] forceMask selects a specific mask (for tests).
* @returns {{matrix: number[][], version: number, size: number, mask: number}}
*/
export function encodeQr(text, opts = {}) {
const bytes = Array.from(new TextEncoder().encode(text));
const version = chooseVersion(bytes.length);
const size = version * 4 + 17;
const codewords = buildCodewords(bytes, version);
const reserved = buildReserved(size, version);
const base = makeBaseMatrix(size);
placeFinder(base, 0, 0);
placeFinder(base, 0, size - 7);
placeFinder(base, size - 7, 0);
placeAlignment(base, version);
placeTiming(base);
placeVersionBits(base, version);
placeData(base, reserved, codewords);
let chosen = opts.forceMask;
let bestMatrix = null;
if (chosen == null) {
let bestScore = Infinity;
for (let mi = 0; mi < 8; mi++) {
const masked = applyMask(base, reserved, MASKS[mi]);
placeFormatBits(masked, mi);
const s = penalty(masked);
if (s < bestScore) {
bestScore = s;
chosen = mi;
bestMatrix = masked;
}
}
} else {
bestMatrix = applyMask(base, reserved, MASKS[chosen]);
placeFormatBits(bestMatrix, chosen);
}
return { matrix: bestMatrix, version, size, mask: chosen };
}
@@ -0,0 +1,669 @@
"use strict";
// ---- tiny helpers -------------------------------------------------------
const $ = (id) => document.getElementById(id);
const nonce = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// The server mints a per-session access token and passes it in this iframe's
// URL. Attach it to every data request so other local origins that guess the
// port cannot read state or drive mutations.
const ACCESS_KEY = new URLSearchParams(location.search).get("k") || "";
function withKey(path) {
const u = new URL(path, location.origin);
if (ACCESS_KEY) u.searchParams.set("k", ACCESS_KEY);
return u.pathname + u.search;
}
async function api(path, body) {
const res = await fetch(withKey(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
if (!res.ok) throw new Error((await res.text()) || res.statusText);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
function toast(msg, ms = 2600) {
const t = $("toast");
t.textContent = msg;
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => (t.hidden = true), ms);
}
// Surface any otherwise-unhandled async-handler failure to the user instead of
// letting it become a silent unhandled rejection with no feedback.
if (typeof window !== "undefined") {
window.addEventListener("unhandledrejection", (e) => {
toast("Error: " + (e.reason?.message || e.reason || "something went wrong"));
});
}
function clampSize(w, h, max = 2048) {
w = Math.max(1, Math.round(w));
h = Math.max(1, Math.round(h));
const longer = Math.max(w, h);
if (longer > max) {
const s = max / longer;
w = Math.max(1, Math.round(w * s));
h = Math.max(1, Math.round(h * s));
}
return { w, h };
}
// ---- state --------------------------------------------------------------
let state = { name: "animation", width: 256, height: 256, loops: 0, hiddenFirst: false, frames: [] };
let assetNonce = nonce();
function defaultDelay() {
const v = parseInt($("in-delay-all").value, 10);
return Number.isFinite(v) && v >= 0 ? v : 120;
}
async function refreshState() {
const res = await fetch(withKey("/state"));
if (!res.ok) throw new Error((await res.text()) || res.statusText);
state = await res.json();
assetNonce = nonce();
render();
}
// ---- rendering ----------------------------------------------------------
function render() {
renderPreview();
renderMeta();
renderSettings();
renderFrames();
syncDrawStage();
}
function renderPreview() {
const img = $("preview");
const empty = $("empty-preview");
const has = state.frames.length > 0;
empty.hidden = has;
if (has) {
img.src = withKey(`/preview.png?n=${assetNonce}`);
img.hidden = false;
img.classList.toggle("pixelated", Math.max(state.width, state.height) < 96);
} else {
img.hidden = true;
}
}
function renderMeta() {
const n = state.frames.length;
const hidden = state.hiddenFirst && n >= 2;
const animated = hidden ? state.frames.slice(1) : state.frames;
const totalMs = Math.round(
animated.reduce((a, f) => a + (f.delayNum || 0) / (f.delayDen || 1000), 0) * 1000
);
$("meta-frames").textContent = `${n} frame${n === 1 ? "" : "s"}${hidden ? " · 1 static" : ""}`;
$("meta-duration").textContent = `${(totalMs / 1000).toFixed(1)}s`;
$("meta-loops").textContent = state.loops === 0 ? "loops ∞" : `loops ${state.loops}`;
const busy = n === 0;
$("btn-export").disabled = busy;
$("btn-download").disabled = busy;
$("btn-restart").disabled = busy;
$("btn-share").disabled = busy;
}
function renderSettings() {
const n = state.frames.length;
const hasFrames = n > 0;
const w = $("in-width"),
h = $("in-height"),
l = $("in-loops");
if (document.activeElement !== w) w.value = state.width;
if (document.activeElement !== h) h.value = state.height;
if (document.activeElement !== l) l.value = state.loops;
w.disabled = hasFrames;
h.disabled = hasFrames;
$("dim-hint").style.display = hasFrames ? "block" : "none";
const hf = $("in-hidden-first");
hf.checked = !!state.hiddenFirst;
hf.disabled = n < 2;
$("hidden-first-label").classList.toggle("disabled", n < 2);
$("hidden-first-hint").hidden = !(state.hiddenFirst && n >= 2);
for (const id of ["btn-delay-all", "btn-fps", "btn-ops-all"]) $(id).disabled = !hasFrames;
}
function renderFrames() {
const strip = $("frame-strip");
const empty = $("empty-frames");
strip.innerHTML = "";
empty.hidden = state.frames.length > 0;
const hidden = state.hiddenFirst && state.frames.length >= 2;
state.frames.forEach((f, i) => {
const isStatic = hidden && i === 0;
const card = document.createElement("div");
card.className = "frame-card" + (isStatic ? " is-static" : "");
const thumbWrap = document.createElement("div");
thumbWrap.className = "checker frame-thumb-wrap";
const thumb = document.createElement("img");
thumb.className = "frame-thumb";
thumb.alt = `Frame ${i + 1}`;
thumb.src = withKey(`/frame?id=${encodeURIComponent(f.id)}&n=${assetNonce}`);
thumbWrap.appendChild(thumb);
if (isStatic) {
const badge = document.createElement("span");
badge.className = "static-badge";
badge.textContent = "STATIC";
thumbWrap.appendChild(badge);
}
const body = document.createElement("div");
body.className = "frame-body";
const idx = document.createElement("div");
idx.className = "frame-index";
idx.textContent = isStatic ? `#${i + 1} · fallback` : `#${i + 1}`;
// Inputs (declared first so the shared commit closure can read them).
const numIn = numberInput(f.delayNum, 0, 65535, "Delay numerator");
const denIn = numberInput(f.delayDen, 1, 65535, "Delay denominator");
const disposeSel = selectEl(
[[0, "None"], [1, "Background"], [2, "Previous"]],
f.disposeOp,
"Dispose op — what to do with the canvas after this frame"
);
const blendSel = selectEl(
[[0, "Source"], [1, "Over"]],
f.blendOp,
"Blend op — how this frame is drawn onto the canvas"
);
const msHint = document.createElement("span");
msHint.className = "ms-hint muted";
const updateHint = () => {
const num = parseInt(numIn.value, 10) || 0;
const den = parseInt(denIn.value, 10) || 1000;
const ms = Math.round((num / den) * 1000);
const fps = num > 0 ? den / num : 0;
const fpsTxt = fps ? ` · ${Number.isInteger(fps) ? fps : fps.toFixed(1)} fps` : "";
msHint.textContent = `= ${ms} ms${fpsTxt}`;
};
updateHint();
const commit = () =>
api("/frames/props", {
id: f.id,
delayNum: parseInt(numIn.value, 10) || 0,
delayDen: parseInt(denIn.value, 10) || 1000,
disposeOp: parseInt(disposeSel.value, 10) || 0,
blendOp: parseInt(blendSel.value, 10) || 0,
}).catch((e) => toast("Error: " + e.message));
numIn.addEventListener("input", updateHint);
denIn.addEventListener("input", updateHint);
numIn.addEventListener("change", commit);
denIn.addEventListener("change", commit);
disposeSel.addEventListener("change", commit);
blendSel.addEventListener("change", commit);
const delayRow = document.createElement("div");
delayRow.className = "frame-field";
delayRow.append(fieldLabel("delay"), numIn, slash(), denIn, msHint);
const opsRow = document.createElement("div");
opsRow.className = "frame-field";
opsRow.append(fieldLabel("dispose"), disposeSel);
const blendRow = document.createElement("div");
blendRow.className = "frame-field";
blendRow.append(fieldLabel("blend"), blendSel);
const actions = document.createElement("div");
actions.className = "frame-actions";
actions.append(
iconBtn("◀", "Move left", i === 0, () => api("/frames/move", { id: f.id, delta: -1 })),
iconBtn("▶", "Move right", i === state.frames.length - 1, () =>
api("/frames/move", { id: f.id, delta: 1 })
),
iconBtn("⧉", "Duplicate", false, () => api("/frames/duplicate", { id: f.id })),
iconBtn("✕", "Delete", false, () => api("/frames/delete", { id: f.id }), true)
);
if (isStatic) {
[numIn, denIn, disposeSel, blendSel].forEach((el) => (el.disabled = true));
msHint.textContent = "not animated";
}
body.append(idx, delayRow, opsRow, blendRow, actions);
card.append(thumbWrap, body);
strip.appendChild(card);
});
}
function numberInput(value, min, max, title) {
const el = document.createElement("input");
el.type = "number";
el.className = "num-in";
el.min = String(min);
el.max = String(max);
el.step = "1";
el.value = value;
el.title = title;
return el;
}
function selectEl(options, value, title) {
const s = document.createElement("select");
s.className = "frame-select";
s.title = title;
for (const [val, text] of options) {
const o = document.createElement("option");
o.value = String(val);
o.textContent = text;
s.appendChild(o);
}
s.value = String(value);
return s;
}
function fieldLabel(text) {
const s = document.createElement("span");
s.className = "frame-label muted";
s.textContent = text;
return s;
}
function slash() {
const s = document.createElement("span");
s.className = "slash muted";
s.textContent = "/";
return s;
}
function iconBtn(label, title, disabled, onClick, danger) {
const b = document.createElement("button");
b.className = "icon-btn" + (danger ? " danger" : "");
b.textContent = label;
b.title = title;
b.setAttribute("aria-label", title);
b.disabled = !!disabled;
b.addEventListener("click", async () => {
try {
await onClick();
} catch (e) {
toast("Error: " + e.message);
}
});
return b;
}
// ---- settings handlers --------------------------------------------------
async function commitLoops() {
await api("/settings", { loops: parseInt($("in-loops").value, 10) || 0 });
}
async function commitDim() {
if (state.frames.length > 0) return;
const w = parseInt($("in-width").value, 10);
const h = parseInt($("in-height").value, 10);
const { w: cw, h: ch } = clampSize(w || state.width, h || state.height);
await api("/settings", { width: cw, height: ch });
}
function debounce(fn, ms) {
let t = null;
return () => {
clearTimeout(t);
t = setTimeout(fn, ms);
};
}
// Commit on `input` (fires for stepper buttons and arrow keys in every engine,
// including the host WebKit view where `change` is unreliable for steppers) as
// well as `change` (final blur/Enter). The `input` path is debounced so holding
// an arrow or typing a value doesn't spam the server.
const commitDimSoon = debounce(commitDim, 300);
const commitLoopsSoon = debounce(commitLoops, 300);
for (const id of ["in-width", "in-height"]) {
$(id).addEventListener("input", commitDimSoon);
$(id).addEventListener("change", commitDim);
}
$("in-loops").addEventListener("input", commitLoopsSoon);
$("in-loops").addEventListener("change", commitLoops);
$("in-hidden-first").addEventListener("change", async (e) => {
await api("/settings", { hiddenFirst: e.target.checked });
});
$("btn-delay-all").addEventListener("click", async () => {
const ms = defaultDelay();
await api("/frames/props-all", { delayMs: ms });
toast(`All delays set to ${ms} ms`);
});
$("btn-fps").addEventListener("click", async () => {
const fps = Math.max(1, Math.min(120, parseInt($("in-fps").value, 10) || 12));
await api("/frames/props-all", { fps });
toast(`All frames set to ${fps} fps`);
});
$("btn-ops-all").addEventListener("click", async () => {
await api("/frames/props-all", {
disposeOp: parseInt($("in-dispose-all").value, 10) || 0,
blendOp: parseInt($("in-blend-all").value, 10) || 0,
});
toast("Applied dispose & blend to all frames");
});
$("btn-clear").addEventListener("click", async () => {
if (!state.frames.length) return;
if (!confirm("Remove all frames?")) return;
await api("/frames/clear", {});
});
// ---- export / download --------------------------------------------------
$("btn-export").addEventListener("click", async () => {
try {
const r = await api("/export", {});
$("saved-path").hidden = false;
$("saved-path").textContent = "Saved: " + r.path;
toast("Exported " + r.name);
} catch (e) {
toast("Export failed: " + e.message);
}
});
$("btn-download").addEventListener("click", async () => {
const res = await fetch(withKey(`/preview.png?n=${nonce()}`));
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = (state.name || "animation").replace(/[^\w.-]+/g, "_") + ".png";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
});
$("btn-restart").addEventListener("click", () => {
// Re-assigning an identical src is a no-op, so bump the nonce to force a
// fresh fetch and restart the animation from the first frame.
assetNonce = nonce();
renderPreview();
});
// ---- send to phone ------------------------------------------------------
let shareCountdown = null;
$("btn-share").addEventListener("click", async () => {
const btn = $("btn-share");
btn.disabled = true;
try {
const info = await api("/share/start", {});
openSharePanel(info);
} catch (e) {
toast("Couldn't start sharing: " + e.message);
} finally {
btn.disabled = state.frames.length === 0;
}
});
$("btn-share-stop").addEventListener("click", stopSharing);
function openSharePanel(info) {
$("share-panel").hidden = false;
// Cache-bust the QR so a new token's code is fetched each time.
$("share-qr-img").src = withKey(`/share/qr.png?ts=${Date.now()}`);
const link = $("share-url");
link.href = info.url;
link.textContent = info.url.replace(/^https?:\/\//, "");
startShareCountdown(info.expiresAt);
}
function startShareCountdown(expiresAt) {
clearInterval(shareCountdown);
const tick = () => {
const ms = expiresAt - Date.now();
if (ms <= 0) {
clearInterval(shareCountdown);
$("share-panel").hidden = true;
toast("Phone link expired");
api("/share/stop", {}).catch(() => {});
return;
}
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
$("share-expiry").textContent = `Expires in ${m}:${String(s).padStart(2, "0")}`;
};
tick();
shareCountdown = setInterval(tick, 1000);
}
async function stopSharing() {
clearInterval(shareCountdown);
$("share-panel").hidden = true;
try {
await api("/share/stop", {});
} catch (_) {
/* server may have already expired the share */
}
}
// ---- upload -------------------------------------------------------------
$("btn-upload").addEventListener("click", () => $("file-input").click());
$("file-input").addEventListener("change", async (e) => {
const files = [...e.target.files];
e.target.value = "";
if (files.length) await addFiles(files);
});
async function addFiles(files) {
try {
toast(`Adding ${files.length} image${files.length === 1 ? "" : "s"}`);
if (state.frames.length === 0) {
const first = await createImageBitmap(files[0]);
const { w, h } = clampSize(first.width, first.height);
first.close?.();
await api("/settings", { width: w, height: h });
await refreshState();
}
for (const f of files) await addImageFile(f);
toast("Frames added");
} catch (e) {
toast("Upload error: " + e.message);
}
}
async function addImageFile(file) {
const bmp = await createImageBitmap(file);
const c = document.createElement("canvas");
c.width = state.width;
c.height = state.height;
try {
drawContain(c.getContext("2d"), bmp, state.width, state.height);
} finally {
// Release the decoded bitmap right after the synchronous draw so a large
// batch doesn't retain native image memory until GC.
bmp.close?.();
}
const blob = await new Promise((r) => c.toBlob(r, "image/png"));
await postFrame(blob, defaultDelay());
}
function drawContain(ctx, img, W, H) {
const s = Math.min(W / img.width, H / img.height);
const dw = img.width * s;
const dh = img.height * s;
ctx.clearRect(0, 0, W, H);
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
}
async function postFrame(blob, delayMs) {
const res = await fetch(withKey(`/frames?delayMs=${delayMs || 0}`), { method: "POST", body: blob });
if (!res.ok) throw new Error(await res.text());
}
// ---- drawing ------------------------------------------------------------
const drawCanvas = $("draw-canvas");
const dctx = drawCanvas.getContext("2d");
let drawTool = "pen";
let drawing = false;
let lastPt = null;
$("btn-toggle-draw").addEventListener("click", () => {
const panel = $("draw-panel");
panel.hidden = !panel.hidden;
if (!panel.hidden) initDrawCanvas();
});
$("btn-cancel-draw").addEventListener("click", () => ($("draw-panel").hidden = true));
$("tool-pen").addEventListener("click", () => setTool("pen"));
$("tool-eraser").addEventListener("click", () => setTool("eraser"));
function setTool(t) {
drawTool = t;
$("tool-pen").classList.toggle("active", t === "pen");
$("tool-eraser").classList.toggle("active", t === "eraser");
$("tool-pen").setAttribute("aria-pressed", String(t === "pen"));
$("tool-eraser").setAttribute("aria-pressed", String(t === "eraser"));
}
$("btn-clear-draw").addEventListener("click", () => {
dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
});
$("btn-fill").addEventListener("click", () => {
dctx.save();
dctx.globalCompositeOperation = "source-over";
dctx.fillStyle = $("draw-color").value;
dctx.fillRect(0, 0, drawCanvas.width, drawCanvas.height);
dctx.restore();
});
$("opt-onion").addEventListener("change", syncOnion);
$("opt-from-last").addEventListener("change", initDrawCanvas);
function syncDrawStage() {
// Size the drawing surface to the project dimensions and scale for display.
if (drawCanvas.width !== state.width || drawCanvas.height !== state.height) {
drawCanvas.width = state.width;
drawCanvas.height = state.height;
}
const longer = Math.max(state.width, state.height) || 1;
const scale = Math.min(360, Math.max(200, longer)) / longer;
const dispW = Math.max(1, Math.round(state.width * scale));
// Drive the display size from the width plus the intrinsic aspect ratio and
// let height follow, so a narrow side panel (CSS max-width) shrinks the
// surface proportionally instead of stretching a fixed height.
const ratio = `${state.width} / ${state.height}`;
drawCanvas.style.width = dispW + "px";
drawCanvas.style.height = "auto";
drawCanvas.style.aspectRatio = ratio;
drawCanvas.style.imageRendering = scale > 1.4 ? "pixelated" : "auto";
const onion = $("onion-img");
onion.style.width = dispW + "px";
onion.style.height = "auto";
onion.style.aspectRatio = ratio;
if (!$("draw-panel").hidden) syncOnion();
}
function lastFrame() {
return state.frames.length ? state.frames[state.frames.length - 1] : null;
}
function syncOnion() {
const onion = $("onion-img");
const lf = lastFrame();
if ($("opt-onion").checked && lf) {
onion.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
onion.hidden = false;
} else {
onion.hidden = true;
}
}
function initDrawCanvas() {
syncDrawStage();
dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
const lf = lastFrame();
if ($("opt-from-last").checked && lf) {
const img = new Image();
img.onload = () => dctx.drawImage(img, 0, 0, drawCanvas.width, drawCanvas.height);
img.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
}
syncOnion();
}
function canvasPoint(e) {
const rect = drawCanvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (drawCanvas.width / rect.width),
y: (e.clientY - rect.top) * (drawCanvas.height / rect.height),
};
}
function strokeTo(pt) {
dctx.globalCompositeOperation = drawTool === "eraser" ? "destination-out" : "source-over";
dctx.strokeStyle = $("draw-color").value;
dctx.fillStyle = $("draw-color").value;
dctx.lineWidth = parseInt($("draw-size").value, 10) || 6;
dctx.lineCap = "round";
dctx.lineJoin = "round";
if (lastPt) {
dctx.beginPath();
dctx.moveTo(lastPt.x, lastPt.y);
dctx.lineTo(pt.x, pt.y);
dctx.stroke();
} else {
dctx.beginPath();
dctx.arc(pt.x, pt.y, dctx.lineWidth / 2, 0, Math.PI * 2);
dctx.fill();
}
lastPt = pt;
}
drawCanvas.addEventListener("pointerdown", (e) => {
drawing = true;
lastPt = null;
drawCanvas.setPointerCapture(e.pointerId);
strokeTo(canvasPoint(e));
});
drawCanvas.addEventListener("pointermove", (e) => {
if (drawing) strokeTo(canvasPoint(e));
});
function endStroke() {
drawing = false;
lastPt = null;
}
drawCanvas.addEventListener("pointerup", endStroke);
drawCanvas.addEventListener("pointerleave", endStroke);
drawCanvas.addEventListener("pointercancel", endStroke);
$("btn-add-drawing").addEventListener("click", async () => {
const blob = await new Promise((r) => drawCanvas.toBlob(r, "image/png"));
await postFrame(blob, defaultDelay());
toast("Frame added");
if ($("opt-from-last").checked) {
// Pull the just-added frame into state before re-seeding the canvas, so
// "start from last frame" copies it instead of the previous frame (or a
// blank canvas on the first add), which the async SSE update may not
// have delivered yet.
await refreshState();
initDrawCanvas();
}
});
// ---- live updates -------------------------------------------------------
let eventSource = null;
function connectEvents() {
try {
if (eventSource) eventSource.close();
eventSource = new EventSource(withKey("/events"));
eventSource.onmessage = () => refreshState();
} catch (_) {
eventSource = null; /* SSE unavailable; manual reload still works */
}
}
$("btn-reload").addEventListener("click", async () => {
const btn = $("btn-reload");
const glyph = $("reload-glyph");
btn.disabled = true;
glyph.classList.add("spinning");
try {
// Recover a dropped live-update stream, then pull the latest state and
// rebuild the preview + thumbnails against a fresh cache-busting nonce.
if (!eventSource || eventSource.readyState === 2) connectEvents();
await refreshState();
toast("Reloaded");
} catch (e) {
toast("Reload failed: " + e.message);
} finally {
glyph.classList.remove("spinning");
btn.disabled = false;
}
});
connectEvents();
refreshState();
@@ -0,0 +1,189 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APNG Studio</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div>
<h1>APNG Studio</h1>
<p class="muted" id="subtitle">Assemble an animated PNG from frames.</p>
</div>
<div class="header-actions">
<button class="btn btn-sm" id="btn-reload"
title="Reload — re-sync frames and rebuild the preview">
<span class="reload-glyph" id="reload-glyph"></span> Reload
</button>
<span class="badge" id="mime-badge">image/apng</span>
</div>
</header>
<!-- Preview -->
<section class="card">
<div class="preview-wrap checker">
<img id="preview" alt="APNG preview" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" />
<div class="empty-preview" id="empty-preview">
<span>No frames yet</span>
<small class="muted">Upload images or draw a frame below.</small>
</div>
</div>
<div class="preview-meta">
<span id="meta-frames">0 frames</span>
<span class="dot">·</span>
<span id="meta-duration">0.0s</span>
<span class="dot">·</span>
<span id="meta-loops">loops ∞</span>
</div>
<div class="row gap">
<button class="btn btn-primary" id="btn-export">Export .png</button>
<button class="btn" id="btn-download">Download</button>
<button class="btn" id="btn-share">Send to phone</button>
<button class="btn btn-ghost" id="btn-restart" title="Restart animation">↺ Replay</button>
</div>
<p class="saved-path muted" id="saved-path" hidden></p>
<!-- Send to phone -->
<div class="share-panel" id="share-panel" hidden>
<div class="share-qr checker">
<img id="share-qr-img" alt="QR code — scan with your phone camera to open the animation" />
</div>
<div class="share-body">
<div class="share-title">Scan with your phone camera</div>
<p class="muted tiny">Your phone must be on the same WiFi as this computer.</p>
<a class="share-url" id="share-url" href="#" target="_blank" rel="noopener"></a>
<div class="share-foot">
<span class="muted tiny" id="share-expiry"></span>
<button class="btn btn-sm btn-ghost" id="btn-share-stop">Stop</button>
</div>
</div>
</div>
</section>
<!-- Settings -->
<section class="card">
<div class="section-title">Settings</div>
<div class="settings-grid">
<label class="field">
<span>Width</span>
<input type="number" id="in-width" min="1" max="2048" step="1" />
</label>
<label class="field">
<span>Height</span>
<input type="number" id="in-height" min="1" max="2048" step="1" />
</label>
<label class="field">
<span>Loops <small class="muted">(0 = ∞)</small></span>
<input type="number" id="in-loops" min="0" max="65535" step="1" />
</label>
<div class="field">
<span>First frame</span>
<label class="chk" id="hidden-first-label">
<input type="checkbox" id="in-hidden-first" /> Static fallback
</label>
</div>
</div>
<p class="muted tiny" id="dim-hint">Canvas size can only change while there are no frames.</p>
<p class="muted tiny" id="hidden-first-hint" hidden>
Frame&nbsp;1 is shown by viewers without APNG support and is not part of the loop.
</p>
<div class="subsection">
<div class="subsection-title">Apply to all frames</div>
<div class="apply-rows">
<span class="inline">
<label class="mini" for="in-delay-all">Delay</label>
<input type="number" id="in-delay-all" min="0" max="65535" step="10" value="120" />
<span class="mini">ms</span>
<button class="btn btn-sm" id="btn-delay-all">Apply</button>
</span>
<span class="inline">
<label class="mini" for="in-fps">Frame rate</label>
<input type="number" id="in-fps" min="1" max="120" step="1" value="12" />
<span class="mini">fps</span>
<button class="btn btn-sm" id="btn-fps">Set</button>
</span>
<span class="inline">
<label class="mini" for="in-dispose-all">Dispose</label>
<select id="in-dispose-all">
<option value="0">None</option>
<option value="1">Background</option>
<option value="2">Previous</option>
</select>
<label class="mini" for="in-blend-all">Blend</label>
<select id="in-blend-all">
<option value="0">Source</option>
<option value="1">Over</option>
</select>
<button class="btn btn-sm" id="btn-ops-all">Apply</button>
</span>
</div>
<details class="help">
<summary>What do dispose &amp; blend do?</summary>
<ul>
<li><b>Blend · Source</b> replaces the canvas region with this frame (alpha included). <b>Over</b> composites this frame on top of what's already there — use it with transparency to build an image up frame by frame.</li>
<li><b>Dispose · None</b> keeps the frame on screen for the next one. <b>Background</b> clears it to transparent first. <b>Previous</b> restores whatever was there before this frame.</li>
</ul>
</details>
</div>
</section>
<!-- Frames -->
<section class="card">
<div class="section-title row between">
<span>Frames</span>
<button class="btn btn-sm btn-ghost danger" id="btn-clear">Clear all</button>
</div>
<div class="frame-strip" id="frame-strip"></div>
<div class="empty-frames muted" id="empty-frames">Frames you add appear here in order.</div>
</section>
<!-- Add frames -->
<section class="card">
<div class="section-title">Add frames</div>
<div class="row gap wrap">
<button class="btn" id="btn-upload">⬆ Upload images</button>
<button class="btn" id="btn-toggle-draw">✎ Draw a frame</button>
<input type="file" id="file-input" accept="image/*" multiple hidden />
</div>
<!-- Draw panel -->
<div class="draw-panel" id="draw-panel" hidden>
<div class="draw-tools">
<label class="tool">
<span>Color</span>
<input type="color" id="draw-color" value="#3b82f6" />
</label>
<label class="tool">
<span>Size</span>
<input type="range" id="draw-size" min="1" max="48" value="6" />
</label>
<div class="tool-group" role="group" aria-label="Tool">
<button class="btn btn-sm tool-btn active" id="tool-pen" aria-pressed="true">Pen</button>
<button class="btn btn-sm tool-btn" id="tool-eraser" aria-pressed="false">Eraser</button>
</div>
<button class="btn btn-sm" id="btn-fill">Fill</button>
<button class="btn btn-sm" id="btn-clear-draw">Clear</button>
</div>
<div class="draw-options">
<label class="chk"><input type="checkbox" id="opt-onion" /> Onion skin</label>
<label class="chk"><input type="checkbox" id="opt-from-last" /> Start from last frame</label>
</div>
<div class="canvas-stage checker" id="canvas-stage">
<img id="onion-img" class="onion" alt="" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" hidden />
<canvas id="draw-canvas"></canvas>
</div>
<div class="row gap">
<button class="btn btn-primary btn-sm" id="btn-add-drawing">Add frame</button>
<button class="btn btn-sm btn-ghost" id="btn-cancel-draw">Close</button>
</div>
</div>
</section>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,620 @@
:root {
--radius: 10px;
--radius-sm: 7px;
--gap: 12px;
--card-bg: var(--background-color-default, #ffffff);
--muted: var(--text-color-muted, #59636e);
--border: var(--border-color-default, #d1d9e0);
--fg: var(--text-color-default, #1f2328);
--accent: var(--true-color-blue, #0969da);
--danger: var(--true-color-red, #cf222e);
}
* {
box-sizing: border-box;
}
/* Ensure the `hidden` attribute always wins over element display rules. */
[hidden] {
display: none !important;
}
body {
margin: 0;
padding: 14px 14px 40px;
background: var(--background-color-default, #ffffff);
color: var(--fg);
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
font-size: var(--text-body-medium, 14px);
line-height: var(--leading-body-medium, 20px);
-webkit-font-smoothing: antialiased;
}
h1 {
font-family: var(--font-sans-display, var(--font-sans, sans-serif));
font-size: var(--text-title-medium, 18px);
font-weight: var(--font-weight-semibold, 600);
line-height: 1.2;
margin: 0;
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--gap);
margin-bottom: 14px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.reload-glyph {
display: inline-block;
}
.reload-glyph.spinning {
animation: apng-spin 0.6s linear infinite;
}
@keyframes apng-spin {
to {
transform: rotate(360deg);
}
}
.muted {
color: var(--muted);
}
.tiny {
font-size: var(--text-caption, 11px);
}
.muted.tiny {
margin: 8px 0 0;
}
.badge {
font-family: var(--font-mono, monospace);
font-size: var(--text-code-inline, 12px);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
#subtitle {
margin: 3px 0 0;
font-size: var(--text-body-small, 12px);
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 14px;
}
.section-title {
font-weight: var(--font-weight-semibold, 600);
margin-bottom: 12px;
}
.row {
display: flex;
align-items: center;
}
.row.gap {
gap: 8px;
}
.row.wrap {
flex-wrap: wrap;
}
.row.between,
.between {
justify-content: space-between;
}
/* Buttons */
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--background-color-default, #fff);
color: var(--fg);
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: var(--text-body-small, 13px);
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
}
.btn:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.04));
}
.btn:active {
transform: translateY(0.5px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-sm {
padding: 4px 9px;
font-size: var(--text-caption, 12px);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--color-white, #fff);
}
.btn-primary:hover {
filter: brightness(1.06);
background: var(--accent);
}
.btn-ghost {
background: transparent;
border-color: transparent;
}
.btn-ghost:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.05));
}
.danger {
color: var(--danger);
}
/* Preview */
.preview-wrap {
position: relative;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
min-height: 160px;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 10px;
}
#preview {
max-width: 100%;
max-height: 300px;
image-rendering: auto;
display: block;
}
#preview.pixelated {
image-rendering: pixelated;
}
.empty-preview {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
background: var(--background-color-default, #fff);
text-align: center;
}
.preview-meta {
display: flex;
align-items: center;
gap: 7px;
justify-content: center;
color: var(--muted);
font-size: var(--text-body-small, 12px);
margin: 10px 0 12px;
}
.preview-meta .dot {
opacity: 0.5;
}
.saved-path {
margin: 10px 0 0;
font-family: var(--font-mono, monospace);
font-size: var(--text-caption, 11px);
word-break: break-all;
}
/* Checkerboard for transparency */
.checker {
--checker-base: var(--background-color-default, #fff);
--checker-square: var(--border-color-default, #d9dbe0);
background-color: var(--checker-base);
background-image:
linear-gradient(45deg, var(--checker-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--checker-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--checker-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--checker-square) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
/* Send to phone */
.share-panel {
display: flex;
gap: var(--gap);
align-items: center;
margin-top: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card-bg);
}
.share-qr {
flex-shrink: 0;
padding: 8px;
border-radius: var(--radius-sm);
line-height: 0;
}
.share-qr img {
width: 132px;
height: 132px;
image-rendering: pixelated;
display: block;
}
.share-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.share-title {
font-weight: var(--font-weight-semibold, 600);
}
.share-url {
font-family: var(--font-mono, monospace);
font-size: var(--text-code-inline, 12px);
color: var(--accent);
word-break: break-all;
text-decoration: none;
margin-top: 2px;
}
.share-url:hover {
text-decoration: underline;
}
.share-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
}
/* Settings */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-body-small, 12px);
color: var(--muted);
}
.field .inline {
display: flex;
gap: 6px;
}
input[type="number"],
input[type="text"] {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-body-medium, 13px);
font-family: inherit;
}
input:focus-visible,
.btn:focus-visible,
select:focus-visible {
outline: 2px solid var(--color-focus-outline, var(--accent));
outline-offset: 1px;
}
/* hidden-first checkbox living inside the settings grid */
.field .chk {
color: var(--fg);
font-size: var(--text-body-small, 12px);
padding: 4px 0;
}
.field .chk.disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Settings subsection: apply-to-all controls */
.subsection {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.subsection-title {
font-size: var(--text-caption, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
margin-bottom: 8px;
}
.apply-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.apply-rows .inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.apply-rows .inline input[type="number"] {
width: 68px;
}
.apply-rows select {
padding: 5px 7px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-body-small, 12px);
font-family: inherit;
}
.mini {
font-size: 11px;
color: var(--muted);
}
.help {
margin-top: 12px;
font-size: var(--text-body-small, 12px);
}
.help summary {
cursor: pointer;
color: var(--accent);
font-size: var(--text-caption, 11px);
}
.help ul {
margin: 8px 0 0;
padding-left: 18px;
color: var(--muted);
}
.help li {
margin-bottom: 5px;
}
.help b {
color: var(--fg);
}
/* Frame strip */
.frame-strip {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
}
.frame-card {
flex: 0 0 auto;
width: 168px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
.frame-card.is-static {
border-color: var(--accent);
border-style: dashed;
}
.frame-thumb-wrap {
position: relative;
border-bottom: 1px solid var(--border);
}
.frame-thumb {
width: 100%;
height: 88px;
object-fit: contain;
display: block;
}
.static-badge {
position: absolute;
top: 5px;
left: 5px;
background: var(--accent);
color: var(--color-white, #fff);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 4px;
}
.frame-body {
padding: 7px;
display: flex;
flex-direction: column;
gap: 6px;
}
.frame-index {
font-size: var(--text-caption, 11px);
color: var(--muted);
font-weight: 600;
}
.frame-field {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.frame-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
min-width: 46px;
}
.num-in {
width: 52px !important;
padding: 3px 4px !important;
font-size: var(--text-caption, 11px) !important;
text-align: center;
}
.slash {
font-size: 11px;
}
.ms-hint {
flex-basis: 100%;
font-size: 10px;
}
.frame-select {
flex: 1;
min-width: 0;
padding: 3px 4px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-caption, 11px);
font-family: inherit;
}
.frame-actions {
display: flex;
gap: 3px;
margin-top: 1px;
}
.icon-btn {
flex: 1;
appearance: none;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
border-radius: 5px;
padding: 3px 0;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.icon-btn:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.05));
}
.icon-btn.danger:hover {
background: var(--true-color-red-muted, rgba(207, 34, 46, 0.1));
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.empty-frames {
font-size: var(--text-body-small, 12px);
padding: 4px 0 0;
}
/* Draw panel */
.draw-panel {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.tool {
display: flex;
align-items: center;
gap: 5px;
font-size: var(--text-caption, 12px);
color: var(--muted);
}
.tool input[type="color"] {
width: 30px;
height: 26px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.tool-group {
display: inline-flex;
gap: 0;
}
.tool-group .tool-btn {
border-radius: 0;
margin-left: -1px;
}
.tool-group .tool-btn:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
margin-left: 0;
}
.tool-group .tool-btn:last-child {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.tool-btn.active {
background: var(--accent);
border-color: var(--accent);
color: var(--color-white, #fff);
}
.draw-options {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.chk {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-body-small, 12px);
cursor: pointer;
}
.canvas-stage {
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 10px;
display: flex;
justify-content: center;
}
.canvas-stage .onion,
.canvas-stage canvas {
position: relative;
max-width: 100%;
display: block;
touch-action: none;
}
.canvas-stage .onion {
position: absolute;
inset: 0;
margin: auto;
opacity: 0.28;
pointer-events: none;
object-fit: contain;
}
#draw-canvas {
cursor: crosshair;
}
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
background: var(--fg);
color: var(--background-color-default, #fff);
padding: 8px 14px;
border-radius: 999px;
font-size: var(--text-body-small, 12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
z-index: 50;
max-width: 90%;
text-align: center;
}
.toast[hidden] {
display: none;
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "arcade-canvas",
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
"version": "1.0.0",
"version": "1.0.1",
"author": {
"name": "Dan Wahlin",
"url": "https://github.com/DanWahlin"
@@ -15,5 +15,5 @@
"session-breaks"
],
"logo": "assets/preview.png",
"extensions": "."
"extensions": "extensions"
}
@@ -0,0 +1,68 @@
# Agent Arcade Canvas
A GitHub Copilot canvas that opens a retro arcade in the side panel. It serves the built Agent Arcade Phaser frontend and lets either the user or the agent switch between five mini-games.
## Games
- **Alien Onslaught** — Space Invaders-style arcade action with marching aliens, shields, and mystery ships.
- **Cosmic Rocks** — Asteroids-style vector shooter with thrust physics and splitting asteroids.
- **Galaxy Blaster** — Galaga-style space shooter with formation enemies, attack patterns, and dual-shot power-up.
- **Ninja Runner** — Classic platformer with double jumps, power-ups, warp pipes, and enemies.
- **Planet Guardian** — Defender-style side-scrolling shooter with humanoid rescues and six enemy types.
## Files
- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions.
- `game/` — compiled Phaser game frontend served inside the canvas.
- `assets/` — game sprites, sounds, app icon, and `preview.png` for the extensions gallery.
- `package.json` — declares the Copilot SDK dependency and ESM entry point.
- `copilot-extension.json` — Copilot extension name/version metadata.
- `canvas.json` — Awesome Copilot gallery metadata.
## Prerequisites
- **Node.js 20.19 or newer** because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
## Install
Drop this folder at `~/.copilot/extensions/arcade-canvas/` for user scope, or in a repository at `.github/extensions/arcade-canvas/` for project scope. Then install dependencies from inside the copied folder:
```sh
# User scope
cd ~/.copilot/extensions/arcade-canvas
# Or project scope, from the repository root
cd .github/extensions/arcade-canvas
npm install
```
Reload extensions in the GitHub Copilot app, then open the `arcade-canvas` canvas. The canvas accepts an optional `defaultGame` input with one of these keys: `cosmic-rocks`, `alien-onslaught`, `galaxy-blaster`, `ninja-runner`, or `defender`.
## Agent actions
- `list_games` — list available mini-games and the currently selected game.
- `select_game { gameKey }` — switch the open arcade canvas to a specific mini-game.
- `restart_game` — reload the open arcade canvas to restart the current game.
## Development
In the Agent Arcade repository, rebuild the committed canvas bundle after frontend or asset changes:
```sh
npm run build:canvas
```
That command builds the frontend, copies `dist/game` into `game/`, copies `dist/assets` into `assets/`, writes `assets/preview.png` for the Awesome Copilot gallery, and bundles `assets/canvas-background.webp` for the canvas-only space backdrop.
## Credits
- Sprite assets: [Simple Platformer 16](https://juhosprite.itch.io/simple-platformer-16) by JuhoSprite.
- Space shooter assets: [Space Shooter Redux](https://opengameart.org/content/space-shooter-redux) by Kenney.nl.
- Galaga-style game mechanics: [WesleyEdwards/galaga](https://github.com/WesleyEdwards/galaga) by Wesley Edwards.
- Asteroids-style game mechanics: [phaser3-typescript](https://github.com/digitsensitive/phaser3-typescript) by digitsensitive.
- Defender-style game mechanics and sound effects: [OpenDefender](https://github.com/mkinney/Opendefender) by mkinney.
- Retro game sound effects: ["Retro game sound effects"](https://opengameart.org/content/retro-game-sound-effects) by Vircon32 (Carra), published at OpenGameArt under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/).
- Thanks to [John Papa](https://github.com/johnpapa) for his Alien Onslaught game PR.
- Thanks to [Shayne Boyer](https://github.com/spboyer) for the initial PR to get Agent Arcade running in the GitHub App canvas.
@@ -0,0 +1,6 @@
https://opengameart.org/content/space-shooter-redux
https://opengameart.org/content/2d-nature-platformer-tileset-16x16
https://opengameart.org/content/retro-game-sound-effects
https://github.com/digitsensitive/phaser3-typescript
https://github.com/WesleyEdwards/galaga
https://juhosprite.itch.io/simple-platformer-16
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

@@ -0,0 +1,297 @@
<?xml version='1.0' encoding='UTF-8'?>
<TextureAtlas imagePath="space_sheet-2.png">
<SubTexture name="beam0.png" x="177" y="463" width="50" height="35" />
<SubTexture name="beam1.png" x="402" y="790" width="46" height="22" />
<SubTexture name="beam2.png" x="322" y="1112" width="44" height="35" />
<SubTexture name="beam3.png" x="486" y="472" width="33" height="33" />
<SubTexture name="beam4.png" x="218" y="609" width="48" height="18" />
<SubTexture name="beam5.png" x="229" y="463" width="46" height="28" />
<SubTexture name="beam6.png" x="148" y="844" width="50" height="26" />
<SubTexture name="beamLong1.png" x="1015" y="1156" width="16" height="80" />
<SubTexture name="beamLong2.png" x="377" y="380" width="28" height="76" />
<SubTexture name="bold_silver.png" x="993" y="1026" width="21" height="34" />
<SubTexture name="bolt_bronze.png" x="993" y="573" width="21" height="34" />
<SubTexture name="bolt_gold.png" x="992" y="537" width="21" height="34" />
<SubTexture name="buttonBlue.png" x="1" y="97" width="269" height="45" />
<SubTexture name="buttonGreen.png" x="1" y="145" width="269" height="45" />
<SubTexture name="buttonRed.png" x="1" y="1" width="269" height="45" />
<SubTexture name="buttonYellow.png" x="1" y="49" width="269" height="45" />
<SubTexture name="cockpitBlue_0.png" x="719" y="1" width="60" height="89" />
<SubTexture name="cockpitBlue_1.png" x="903" y="1057" width="46" height="46" />
<SubTexture name="cockpitBlue_2.png" x="839" y="84" width="49" height="66" />
<SubTexture name="cockpitBlue_3.png" x="413" y="472" width="71" height="72" />
<SubTexture name="cockpitBlue_4.png" x="782" y="1" width="55" height="80" />
<SubTexture name="cockpitBlue_5.png" x="769" y="178" width="56" height="89" />
<SubTexture name="cockpitBlue_6.png" x="839" y="1" width="49" height="80" />
<SubTexture name="cockpitBlue_7.png" x="904" y="665" width="48" height="84" />
<SubTexture name="cockpitGreen_0.png" x="707" y="277" width="60" height="89" />
<SubTexture name="cockpitGreen_1.png" x="900" y="1198" width="46" height="46" />
<SubTexture name="cockpitGreen_2.png" x="854" y="809" width="49" height="66" />
<SubTexture name="cockpitGreen_3.png" x="425" y="288" width="71" height="72" />
<SubTexture name="cockpitGreen_4.png" x="769" y="270" width="55" height="80" />
<SubTexture name="cockpitGreen_5.png" x="851" y="447" width="49" height="80" />
<SubTexture name="cockpitGreen_6.png" x="904" y="578" width="48" height="84" />
<SubTexture name="cockpitGreen_7.png" x="739" y="644" width="56" height="89" />
<SubTexture name="cockpitRed_0.png" x="657" y="93" width="60" height="89" />
<SubTexture name="cockpitRed_1.png" x="431" y="1204" width="46" height="46" />
<SubTexture name="cockpitRed_2.png" x="881" y="243" width="49" height="66" />
<SubTexture name="cockpitRed_3.png" x="638" y="811" width="71" height="72" />
<SubTexture name="cockpitRed_4.png" x="794" y="1051" width="55" height="80" />
<SubTexture name="cockpitRed_5.png" x="742" y="867" width="56" height="89" />
<SubTexture name="cockpitRed_6.png" x="903" y="975" width="49" height="80" />
<SubTexture name="cockpitRed_7.png" x="903" y="404" width="48" height="84" />
<SubTexture name="cockpitYellow_0.png" x="891" y="99" width="46" height="46" />
<SubTexture name="cockpitYellow_1.png" x="304" y="380" width="71" height="72" />
<SubTexture name="cockpitYellow_2.png" x="782" y="84" width="55" height="80" />
<SubTexture name="cockpitYellow_3.png" x="745" y="959" width="56" height="89" />
<SubTexture name="cockpitYellow_4.png" x="854" y="322" width="49" height="80" />
<SubTexture name="cockpitYellow_5.png" x="903" y="491" width="48" height="84" />
<SubTexture name="cockpitYellow_6.png" x="900" y="1129" width="49" height="66" />
<SubTexture name="cockpitYellow_7.png" x="736" y="461" width="60" height="89" />
<SubTexture name="cursor.png" x="978" y="213" width="34" height="38" />
<SubTexture name="enemyBlack1.png" x="520" y="893" width="111" height="100" />
<SubTexture name="enemyBlack2.png" x="148" y="741" width="125" height="100" />
<SubTexture name="enemyBlack3.png" x="178" y="193" width="124" height="100" />
<SubTexture name="enemyBlack4.png" x="636" y="399" width="98" height="100" />
<SubTexture name="enemyBlack5.png" x="425" y="185" width="116" height="100" />
<SubTexture name="enemyBlue1.png" x="522" y="575" width="111" height="100" />
<SubTexture name="enemyBlue2.png" x="177" y="360" width="125" height="100" />
<SubTexture name="enemyBlue3.png" x="273" y="1" width="124" height="100" />
<SubTexture name="enemyBlue4.png" x="636" y="502" width="98" height="100" />
<SubTexture name="enemyBlue5.png" x="517" y="998" width="116" height="100" />
<SubTexture name="enemyGreen1.png" x="522" y="677" width="111" height="100" />
<SubTexture name="enemyGreen2.png" x="164" y="506" width="125" height="100" />
<SubTexture name="enemyGreen3.png" x="276" y="609" width="124" height="100" />
<SubTexture name="enemyGreen4.png" x="636" y="605" width="98" height="100" />
<SubTexture name="enemyGreen5.png" x="501" y="1112" width="116" height="100" />
<SubTexture name="enemyRed1.png" x="522" y="472" width="111" height="100" />
<SubTexture name="enemyRed2.png" x="148" y="638" width="125" height="100" />
<SubTexture name="enemyRed3.png" x="276" y="712" width="124" height="100" />
<SubTexture name="enemyRed4.png" x="638" y="708" width="98" height="100" />
<SubTexture name="enemyRed5.png" x="520" y="790" width="116" height="100" />
<SubTexture name="engine1.png" x="276" y="1112" width="44" height="26" />
<SubTexture name="engine2.png" x="201" y="844" width="49" height="32" />
<SubTexture name="engine3.png" x="790" y="1229" width="31" height="24" />
<SubTexture name="engine4.png" x="178" y="295" width="58" height="53" />
<SubTexture name="engine5.png" x="164" y="609" width="51" height="27" />
<SubTexture name="fire00.png" x="1014" y="155" width="17" height="46" />
<SubTexture name="fire01.png" x="1015" y="254" width="15" height="35" />
<SubTexture name="fire02.png" x="1014" y="813" width="15" height="37" />
<SubTexture name="fire03.png" x="1017" y="537" width="15" height="39" />
<SubTexture name="fire04.png" x="1019" y="1" width="15" height="35" />
<SubTexture name="fire05.png" x="1023" y="368" width="15" height="35" />
<SubTexture name="fire06.png" x="1024" y="616" width="15" height="35" />
<SubTexture name="fire07.png" x="1024" y="406" width="15" height="35" />
<SubTexture name="fire08.png" x="1014" y="1063" width="17" height="46" />
<SubTexture name="fire09.png" x="995" y="813" width="17" height="46" />
<SubTexture name="fire10.png" x="996" y="254" width="17" height="46" />
<SubTexture name="fire11.png" x="1024" y="485" width="15" height="35" />
<SubTexture name="fire12.png" x="1024" y="654" width="15" height="37" />
<SubTexture name="fire13.png" x="1024" y="444" width="15" height="39" />
<SubTexture name="fire14.png" x="1019" y="39" width="15" height="35" />
<SubTexture name="fire15.png" x="1017" y="578" width="15" height="35" />
<SubTexture name="fire16.png" x="1015" y="330" width="15" height="35" />
<SubTexture name="fire17.png" x="1015" y="292" width="15" height="35" />
<SubTexture name="fire18.png" x="1014" y="204" width="17" height="48" />
<SubTexture name="fire19.png" x="996" y="303" width="17" height="48" />
<SubTexture name="gun00.png" x="1014" y="1112" width="17" height="42" />
<SubTexture name="gun01.png" x="993" y="1063" width="18" height="38" />
<SubTexture name="gun02.png" x="1017" y="750" width="15" height="42" />
<SubTexture name="gun03.png" x="992" y="976" width="22" height="48" />
<SubTexture name="gun04.png" x="1014" y="104" width="17" height="48" />
<SubTexture name="gun05.png" x="520" y="1" width="23" height="48" />
<SubTexture name="gun06.png" x="993" y="1104" width="18" height="44" />
<SubTexture name="gun07.png" x="1017" y="976" width="15" height="48" />
<SubTexture name="gun08.png" x="1040" y="324" width="10" height="55" />
<SubTexture name="gun09.png" x="992" y="750" width="22" height="61" />
<SubTexture name="gun10.png" x="991" y="1178" width="22" height="61" />
<SubTexture name="laserBlue01.png" x="1050" y="517" width="9" height="64" />
<SubTexture name="laserBlue02.png" x="1031" y="794" width="13" height="43" />
<SubTexture name="laserBlue03.png" x="1050" y="71" width="9" height="43" />
<SubTexture name="laserBlue04.png" x="1024" y="693" width="13" height="43" />
<SubTexture name="laserBlue05.png" x="1052" y="583" width="9" height="43" />
<SubTexture name="laserBlue06.png" x="1024" y="922" width="13" height="43" />
<SubTexture name="laserBlue07.png" x="1050" y="951" width="9" height="43" />
<SubTexture name="laserBlue08.png" x="731" y="1178" width="56" height="54" />
<SubTexture name="laserBlue09.png" x="533" y="399" width="56" height="54" />
<SubTexture name="laserBlue10.png" x="908" y="888" width="43" height="43" />
<SubTexture name="laserBlue11.png" x="856" y="975" width="44" height="43" />
<SubTexture name="laserBlue12.png" x="1024" y="853" width="13" height="67" />
<SubTexture name="laserBlue13.png" x="1050" y="1066" width="9" height="67" />
<SubTexture name="laserBlue14.png" x="1033" y="254" width="13" height="67" />
<SubTexture name="laserBlue15.png" x="1041" y="589" width="9" height="67" />
<SubTexture name="laserBlue16.png" x="1034" y="77" width="13" height="64" />
<SubTexture name="laserGreen01.png" x="908" y="842" width="43" height="44" />
<SubTexture name="laserGreen02.png" x="1034" y="144" width="13" height="67" />
<SubTexture name="laserGreen03.png" x="1049" y="213" width="9" height="67" />
<SubTexture name="laserGreen04.png" x="1040" y="693" width="13" height="43" />
<SubTexture name="laserGreen05.png" x="1047" y="784" width="9" height="43" />
<SubTexture name="laserGreen06.png" x="1036" y="1" width="13" height="67" />
<SubTexture name="laserGreen07.png" x="1041" y="447" width="9" height="67" />
<SubTexture name="laserGreen08.png" x="1040" y="905" width="13" height="43" />
<SubTexture name="laserGreen09.png" x="1050" y="117" width="9" height="43" />
<SubTexture name="laserGreen10.png" x="1034" y="523" width="13" height="64" />
<SubTexture name="laserGreen11.png" x="1041" y="381" width="9" height="64" />
<SubTexture name="laserGreen12.png" x="1034" y="739" width="13" height="43" />
<SubTexture name="laserGreen13.png" x="1052" y="1" width="9" height="43" />
<SubTexture name="laserGreen14.png" x="238" y="295" width="56" height="54" />
<SubTexture name="laserGreen15.png" x="544" y="224" width="56" height="54" />
<SubTexture name="laserGreen16.png" x="932" y="237" width="43" height="43" />
<SubTexture name="laserRed01.png" x="1052" y="283" width="9" height="64" />
<SubTexture name="laserRed02.png" x="1034" y="1198" width="13" height="43" />
<SubTexture name="laserRed03.png" x="1050" y="739" width="9" height="43" />
<SubTexture name="laserRed04.png" x="1034" y="1153" width="13" height="43" />
<SubTexture name="laserRed05.png" x="1050" y="1205" width="9" height="43" />
<SubTexture name="laserRed06.png" x="1034" y="1107" width="13" height="43" />
<SubTexture name="laserRed07.png" x="1050" y="162" width="9" height="43" />
<SubTexture name="laserRed08.png" x="712" y="811" width="56" height="54" />
<SubTexture name="laserRed09.png" x="739" y="736" width="56" height="54" />
<SubTexture name="laserRed10.png" x="905" y="797" width="43" height="42" />
<SubTexture name="laserRed11.png" x="904" y="752" width="43" height="43" />
<SubTexture name="laserRed12.png" x="1034" y="1038" width="13" height="67" />
<SubTexture name="laserRed13.png" x="1050" y="996" width="9" height="67" />
<SubTexture name="laserRed14.png" x="1034" y="968" width="13" height="67" />
<SubTexture name="laserRed15.png" x="1050" y="1135" width="9" height="67" />
<SubTexture name="laserRed16.png" x="1040" y="839" width="13" height="64" />
<SubTexture name="meteorBrown_big1.png" x="276" y="815" width="121" height="100" />
<SubTexture name="meteorBrown_big2.png" x="1" y="638" width="144" height="118" />
<SubTexture name="meteorBrown_big3.png" x="636" y="993" width="106" height="98" />
<SubTexture name="meteorBrown_big4.png" x="402" y="555" width="118" height="115" />
<SubTexture name="meteorBrown_med1.png" x="799" y="549" width="50" height="50" />
<SubTexture name="meteorBrown_med3.png" x="292" y="555" width="53" height="46" />
<SubTexture name="meteorBrown_small1.png" x="499" y="288" width="32" height="32" />
<SubTexture name="meteorBrown_small2.png" x="954" y="720" width="33" height="29" />
<SubTexture name="meteorBrown_tiny1.png" x="425" y="998" width="20" height="20" />
<SubTexture name="meteorBrown_tiny2.png" x="490" y="998" width="17" height="16" />
<SubTexture name="meteorGrey_big1.png" x="276" y="918" width="121" height="100" />
<SubTexture name="meteorGrey_big2.png" x="1" y="758" width="144" height="118" />
<SubTexture name="meteorGrey_big3.png" x="633" y="893" width="106" height="98" />
<SubTexture name="meteorGrey_big4.png" x="402" y="673" width="118" height="115" />
<SubTexture name="meteorGrey_med1.png" x="827" y="270" width="50" height="50" />
<SubTexture name="meteorGrey_med2.png" x="347" y="555" width="53" height="46" />
<SubTexture name="meteorGrey_small1.png" x="499" y="322" width="32" height="32" />
<SubTexture name="meteorGrey_small2.png" x="486" y="507" width="33" height="29" />
<SubTexture name="meteorGrey_tiny1.png" x="447" y="998" width="20" height="20" />
<SubTexture name="meteorGrey_tiny2.png" x="739" y="793" width="17" height="16" />
<SubTexture name="numeral0.png" x="451" y="790" width="21" height="21" />
<SubTexture name="numeral1.png" x="253" y="844" width="21" height="21" />
<SubTexture name="numeral2.png" x="499" y="357" width="21" height="21" />
<SubTexture name="numeral3.png" x="712" y="867" width="21" height="21" />
<SubTexture name="numeral4.png" x="474" y="790" width="21" height="21" />
<SubTexture name="numeral5.png" x="771" y="793" width="21" height="21" />
<SubTexture name="numeral6.png" x="823" y="1229" width="21" height="21" />
<SubTexture name="numeral7.png" x="846" y="1231" width="21" height="21" />
<SubTexture name="numeral8.png" x="870" y="1231" width="21" height="21" />
<SubTexture name="numeral9.png" x="603" y="265" width="21" height="21" />
<SubTexture name="numeralX.png" x="469" y="998" width="18" height="18" />
<SubTexture name="pill_blue.png" x="827" y="322" width="24" height="23" />
<SubTexture name="pill_green.png" x="703" y="1213" width="24" height="23" />
<SubTexture name="pill_red.png" x="273" y="134" width="24" height="23" />
<SubTexture name="pill_yellow.png" x="273" y="159" width="24" height="23" />
<SubTexture name="playerLife1_blue.png" x="592" y="440" width="38" height="29" />
<SubTexture name="playerLife1_green.png" x="657" y="185" width="38" height="29" />
<SubTexture name="playerLife1_orange.png" x="953" y="402" width="38" height="29" />
<SubTexture name="playerLife1_red.png" x="951" y="370" width="38" height="29" />
<SubTexture name="playerLife2_blue.png" x="571" y="1215" width="43" height="29" />
<SubTexture name="playerLife2_green.png" x="480" y="1215" width="43" height="29" />
<SubTexture name="playerLife2_orange.png" x="526" y="1215" width="43" height="29" />
<SubTexture name="playerLife2_red.png" x="616" y="1215" width="43" height="29" />
<SubTexture name="playerLife3_blue.png" x="953" y="473" width="37" height="29" />
<SubTexture name="playerLife3_green.png" x="954" y="576" width="37" height="29" />
<SubTexture name="playerLife3_orange.png" x="953" y="873" width="37" height="29" />
<SubTexture name="playerLife3_red.png" x="953" y="544" width="37" height="29" />
<SubTexture name="playerShip1_blue.png" x="260" y="1154" width="119" height="89" />
<SubTexture name="playerShip1_damage1.png" x="139" y="1154" width="119" height="89" />
<SubTexture name="playerShip1_damage2.png" x="304" y="288" width="119" height="89" />
<SubTexture name="playerShip1_damage3.png" x="304" y="196" width="119" height="89" />
<SubTexture name="playerShip1_green.png" x="292" y="463" width="119" height="89" />
<SubTexture name="playerShip1_orange.png" x="304" y="104" width="119" height="89" />
<SubTexture name="playerShip1_red.png" x="276" y="1020" width="119" height="89" />
<SubTexture name="playerShip2_blue.png" x="139" y="970" width="135" height="89" />
<SubTexture name="playerShip2_damage1.png" x="1" y="1062" width="135" height="89" />
<SubTexture name="playerShip2_damage2.png" x="1" y="970" width="135" height="89" />
<SubTexture name="playerShip2_damage3.png" x="1" y="878" width="135" height="89" />
<SubTexture name="playerShip2_green.png" x="139" y="1062" width="135" height="89" />
<SubTexture name="playerShip2_orange.png" x="139" y="878" width="135" height="89" />
<SubTexture name="playerShip2_red.png" x="1" y="1154" width="135" height="89" />
<SubTexture name="playerShip3_blue.png" x="399" y="906" width="118" height="89" />
<SubTexture name="playerShip3_damage1.png" x="397" y="1020" width="118" height="89" />
<SubTexture name="playerShip3_damage2.png" x="381" y="1112" width="118" height="89" />
<SubTexture name="playerShip3_damage3.png" x="399" y="815" width="118" height="89" />
<SubTexture name="playerShip3_green.png" x="425" y="93" width="118" height="89" />
<SubTexture name="playerShip3_orange.png" x="413" y="380" width="118" height="89" />
<SubTexture name="playerShip3_red.png" x="399" y="1" width="118" height="89" />
<SubTexture name="powerupBlue.png" x="854" y="404" width="39" height="38" />
<SubTexture name="powerupBlue_bolt.png" x="662" y="1213" width="39" height="38" />
<SubTexture name="powerupBlue_shield.png" x="953" y="833" width="39" height="38" />
<SubTexture name="powerupBlue_star.png" x="952" y="1098" width="39" height="38" />
<SubTexture name="powerupGreen.png" x="949" y="752" width="39" height="38" />
<SubTexture name="powerupGreen_bolt.png" x="940" y="99" width="39" height="38" />
<SubTexture name="powerupGreen_shield.png" x="952" y="1057" width="39" height="38" />
<SubTexture name="powerupGreen_star.png" x="799" y="602" width="39" height="38" />
<SubTexture name="powerupRed.png" x="603" y="224" width="39" height="38" />
<SubTexture name="powerupRed_bolt.png" x="951" y="793" width="39" height="38" />
<SubTexture name="powerupRed_shield.png" x="952" y="1138" width="39" height="38" />
<SubTexture name="powerupRed_star.png" x="949" y="1198" width="39" height="38" />
<SubTexture name="powerupYellow.png" x="949" y="933" width="39" height="38" />
<SubTexture name="powerupYellow_bolt.png" x="908" y="933" width="39" height="38" />
<SubTexture name="powerupYellow_shield.png" x="592" y="399" width="39" height="38" />
<SubTexture name="powerupYellow_star.png" x="745" y="1051" width="39" height="38" />
<SubTexture name="scratch1.png" x="399" y="998" width="23" height="17" />
<SubTexture name="scratch2.png" x="520" y="52" width="23" height="17" />
<SubTexture name="scratch3.png" x="425" y="363" width="17" height="12" />
<SubTexture name="shield1.png" x="1" y="506" width="160" height="130" />
<SubTexture name="shield2.png" x="1" y="360" width="173" height="143" />
<SubTexture name="shield3.png" x="1" y="193" width="174" height="165" />
<SubTexture name="shield_bronze.png" x="978" y="177" width="34" height="34" />
<SubTexture name="shield_gold.png" x="978" y="140" width="34" height="34" />
<SubTexture name="shield_silver.png" x="954" y="1011" width="34" height="34" />
<SubTexture name="speed.png" x="1052" y="349" width="6" height="130" />
<SubTexture name="star1.png" x="771" y="835" width="28" height="27" />
<SubTexture name="star2.png" x="273" y="104" width="28" height="27" />
<SubTexture name="star3.png" x="707" y="369" width="27" height="27" />
<SubTexture name="star_bronze.png" x="954" y="974" width="35" height="34" />
<SubTexture name="star_gold.png" x="954" y="684" width="35" height="34" />
<SubTexture name="star_silver.png" x="954" y="647" width="35" height="34" />
<SubTexture name="things_bronze.png" x="954" y="608" width="37" height="37" />
<SubTexture name="things_gold.png" x="953" y="505" width="37" height="37" />
<SubTexture name="things_silver.png" x="953" y="434" width="37" height="37" />
<SubTexture name="turretBase_big.png" x="381" y="1204" width="48" height="48" />
<SubTexture name="turretBase_small.png" x="991" y="370" width="29" height="29" />
<SubTexture name="ufoBlue.png" x="545" y="113" width="109" height="109" />
<SubTexture name="ufoGreen.png" x="533" y="288" width="109" height="109" />
<SubTexture name="ufoRed.png" x="545" y="1" width="109" height="109" />
<SubTexture name="ufoYellow.png" x="620" y="1101" width="109" height="109" />
<SubTexture name="wingBlue_0.png" x="794" y="1133" width="53" height="93" />
<SubTexture name="wingBlue_1.png" x="905" y="311" width="43" height="86" />
<SubTexture name="wingBlue_2.png" x="987" y="1" width="29" height="100" />
<SubTexture name="wingBlue_3.png" x="736" y="553" width="60" height="89" />
<SubTexture name="wingBlue_4.png" x="849" y="1133" width="49" height="95" />
<SubTexture name="wingBlue_5.png" x="731" y="1094" width="60" height="82" />
<SubTexture name="wingBlue_6.png" x="851" y="1039" width="49" height="88" />
<SubTexture name="wingBlue_7.png" x="828" y="166" width="50" height="99" />
<SubTexture name="wingGreen_0.png" x="797" y="644" width="53" height="93" />
<SubTexture name="wingGreen_1.png" x="951" y="282" width="43" height="86" />
<SubTexture name="wingGreen_2.png" x="992" y="647" width="29" height="100" />
<SubTexture name="wingGreen_3.png" x="657" y="1" width="60" height="89" />
<SubTexture name="wingGreen_4.png" x="851" y="529" width="49" height="95" />
<SubTexture name="wingGreen_5.png" x="644" y="309" width="60" height="82" />
<SubTexture name="wingGreen_6.png" x="853" y="627" width="49" height="88" />
<SubTexture name="wingGreen_7.png" x="804" y="937" width="50" height="99" />
<SubTexture name="wingRed_0.png" x="992" y="873" width="29" height="100" />
<SubTexture name="wingRed_1.png" x="942" y="1" width="43" height="86" />
<SubTexture name="wingRed_2.png" x="736" y="369" width="60" height="89" />
<SubTexture name="wingRed_3.png" x="856" y="877" width="49" height="95" />
<SubTexture name="wingRed_4.png" x="719" y="93" width="60" height="82" />
<SubTexture name="wingRed_5.png" x="881" y="152" width="49" height="88" />
<SubTexture name="wingRed_6.png" x="801" y="835" width="50" height="99" />
<SubTexture name="wingRed_7.png" x="799" y="352" width="53" height="93" />
<SubTexture name="wingYellow_0.png" x="797" y="740" width="53" height="93" />
<SubTexture name="wingYellow_1.png" x="932" y="148" width="43" height="86" />
<SubTexture name="wingYellow_2.png" x="992" y="434" width="29" height="100" />
<SubTexture name="wingYellow_3.png" x="707" y="185" width="60" height="89" />
<SubTexture name="wingYellow_4.png" x="891" y="1" width="49" height="95" />
<SubTexture name="wingYellow_5.png" x="644" y="224" width="60" height="82" />
<SubTexture name="wingYellow_6.png" x="853" y="718" width="49" height="88" />
<SubTexture name="wingYellow_7.png" x="799" y="447" width="50" height="99" />
</TextureAtlas>
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

@@ -0,0 +1,296 @@
<TextureAtlas imagePath="sheet.png">
<SubTexture name="beam0.png" x="143" y="377" width="43" height="31"/>
<SubTexture name="beam1.png" x="327" y="644" width="40" height="20"/>
<SubTexture name="beam2.png" x="262" y="907" width="38" height="31"/>
<SubTexture name="beam3.png" x="396" y="384" width="29" height="29"/>
<SubTexture name="beam4.png" x="177" y="496" width="41" height="17"/>
<SubTexture name="beam5.png" x="186" y="377" width="40" height="25"/>
<SubTexture name="beam6.png" x="120" y="688" width="43" height="23"/>
<SubTexture name="beamLong1.png" x="828" y="943" width="15" height="67"/>
<SubTexture name="beamLong2.png" x="307" y="309" width="25" height="64"/>
<SubTexture name="bold_silver.png" x="810" y="837" width="19" height="30"/>
<SubTexture name="bolt_bronze.png" x="810" y="467" width="19" height="30"/>
<SubTexture name="bolt_gold.png" x="809" y="437" width="19" height="30"/>
<SubTexture name="buttonBlue.png" x="0" y="78" width="222" height="39"/>
<SubTexture name="buttonGreen.png" x="0" y="117" width="222" height="39"/>
<SubTexture name="buttonRed.png" x="0" y="0" width="222" height="39"/>
<SubTexture name="buttonYellow.png" x="0" y="39" width="222" height="39"/>
<SubTexture name="cockpitBlue_0.png" x="586" y="0" width="51" height="75"/>
<SubTexture name="cockpitBlue_1.png" x="736" y="862" width="40" height="40"/>
<SubTexture name="cockpitBlue_2.png" x="684" y="67" width="42" height="56"/>
<SubTexture name="cockpitBlue_3.png" x="336" y="384" width="60" height="61"/>
<SubTexture name="cockpitBlue_4.png" x="637" y="0" width="47" height="67"/>
<SubTexture name="cockpitBlue_5.png" x="627" y="144" width="48" height="75"/>
<SubTexture name="cockpitBlue_6.png" x="684" y="0" width="42" height="67"/>
<SubTexture name="cockpitBlue_7.png" x="737" y="542" width="41" height="71"/>
<SubTexture name="cockpitGreen_0.png" x="576" y="225" width="51" height="75"/>
<SubTexture name="cockpitGreen_1.png" x="734" y="977" width="40" height="40"/>
<SubTexture name="cockpitGreen_2.png" x="696" y="659" width="42" height="56"/>
<SubTexture name="cockpitGreen_3.png" x="346" y="234" width="60" height="61"/>
<SubTexture name="cockpitGreen_4.png" x="627" y="219" width="47" height="67"/>
<SubTexture name="cockpitGreen_5.png" x="694" y="364" width="42" height="67"/>
<SubTexture name="cockpitGreen_6.png" x="737" y="471" width="41" height="71"/>
<SubTexture name="cockpitGreen_7.png" x="602" y="525" width="48" height="75"/>
<SubTexture name="cockpitRed_0.png" x="535" y="75" width="51" height="75"/>
<SubTexture name="cockpitRed_1.png" x="351" y="982" width="40" height="40"/>
<SubTexture name="cockpitRed_2.png" x="718" y="197" width="42" height="56"/>
<SubTexture name="cockpitRed_3.png" x="520" y="661" width="60" height="61"/>
<SubTexture name="cockpitRed_4.png" x="647" y="857" width="47" height="67"/>
<SubTexture name="cockpitRed_5.png" x="605" y="707" width="48" height="75"/>
<SubTexture name="cockpitRed_6.png" x="736" y="795" width="42" height="67"/>
<SubTexture name="cockpitRed_7.png" x="736" y="329" width="41" height="71"/>
<SubTexture name="cockpitYellow_0.png" x="726" y="80" width="40" height="40"/>
<SubTexture name="cockpitYellow_1.png" x="247" y="309" width="60" height="61"/>
<SubTexture name="cockpitYellow_2.png" x="637" y="67" width="47" height="67"/>
<SubTexture name="cockpitYellow_3.png" x="607" y="782" width="48" height="75"/>
<SubTexture name="cockpitYellow_4.png" x="696" y="262" width="42" height="67"/>
<SubTexture name="cockpitYellow_5.png" x="736" y="400" width="41" height="71"/>
<SubTexture name="cockpitYellow_6.png" x="734" y="921" width="42" height="56"/>
<SubTexture name="cockpitYellow_7.png" x="600" y="375" width="51" height="75"/>
<SubTexture name="cursor.png" x="797" y="173" width="30" height="33"/>
<SubTexture name="enemyBlack1.png" x="423" y="728" width="93" height="84"/>
<SubTexture name="enemyBlack2.png" x="120" y="604" width="104" height="84"/>
<SubTexture name="enemyBlack3.png" x="144" y="156" width="103" height="84"/>
<SubTexture name="enemyBlack4.png" x="518" y="325" width="82" height="84"/>
<SubTexture name="enemyBlack5.png" x="346" y="150" width="97" height="84"/>
<SubTexture name="enemyBlue1.png" x="425" y="468" width="93" height="84"/>
<SubTexture name="enemyBlue2.png" x="143" y="293" width="104" height="84"/>
<SubTexture name="enemyBlue3.png" x="222" y="0" width="103" height="84"/>
<SubTexture name="enemyBlue4.png" x="518" y="409" width="82" height="84"/>
<SubTexture name="enemyBlue5.png" x="421" y="814" width="97" height="84"/>
<SubTexture name="enemyGreen1.png" x="425" y="552" width="93" height="84"/>
<SubTexture name="enemyGreen2.png" x="133" y="412" width="104" height="84"/>
<SubTexture name="enemyGreen3.png" x="224" y="496" width="103" height="84"/>
<SubTexture name="enemyGreen4.png" x="518" y="493" width="82" height="84"/>
<SubTexture name="enemyGreen5.png" x="408" y="907" width="97" height="84"/>
<SubTexture name="enemyRed1.png" x="425" y="384" width="93" height="84"/>
<SubTexture name="enemyRed2.png" x="120" y="520" width="104" height="84"/>
<SubTexture name="enemyRed3.png" x="224" y="580" width="103" height="84"/>
<SubTexture name="enemyRed4.png" x="520" y="577" width="82" height="84"/>
<SubTexture name="enemyRed5.png" x="423" y="644" width="97" height="84"/>
<SubTexture name="engine1.png" x="224" y="907" width="38" height="23"/>
<SubTexture name="engine2.png" x="163" y="688" width="42" height="28"/>
<SubTexture name="engine3.png" x="644" y="1002" width="27" height="22"/>
<SubTexture name="engine4.png" x="144" y="240" width="49" height="45"/>
<SubTexture name="engine5.png" x="133" y="496" width="44" height="24"/>
<SubTexture name="fire00.png" x="827" y="125" width="16" height="40"/>
<SubTexture name="fire01.png" x="828" y="206" width="14" height="31"/>
<SubTexture name="fire02.png" x="827" y="663" width="14" height="32"/>
<SubTexture name="fire03.png" x="829" y="437" width="14" height="34"/>
<SubTexture name="fire04.png" x="831" y="0" width="14" height="31"/>
<SubTexture name="fire05.png" x="834" y="299" width="14" height="31"/>
<SubTexture name="fire06.png" x="835" y="502" width="14" height="31"/>
<SubTexture name="fire07.png" x="835" y="330" width="14" height="31"/>
<SubTexture name="fire08.png" x="827" y="867" width="16" height="40"/>
<SubTexture name="fire09.png" x="811" y="663" width="16" height="40"/>
<SubTexture name="fire10.png" x="812" y="206" width="16" height="40"/>
<SubTexture name="fire11.png" x="835" y="395" width="14" height="31"/>
<SubTexture name="fire12.png" x="835" y="533" width="14" height="32"/>
<SubTexture name="fire13.png" x="835" y="361" width="14" height="34"/>
<SubTexture name="fire14.png" x="831" y="31" width="14" height="31"/>
<SubTexture name="fire15.png" x="829" y="471" width="14" height="31"/>
<SubTexture name="fire16.png" x="828" y="268" width="14" height="31"/>
<SubTexture name="fire17.png" x="828" y="237" width="14" height="31"/>
<SubTexture name="fire18.png" x="827" y="165" width="16" height="41"/>
<SubTexture name="fire19.png" x="812" y="246" width="16" height="41"/>
<SubTexture name="gun00.png" x="827" y="907" width="16" height="36"/>
<SubTexture name="gun01.png" x="810" y="867" width="17" height="33"/>
<SubTexture name="gun02.png" x="829" y="611" width="14" height="36"/>
<SubTexture name="gun03.png" x="809" y="796" width="20" height="41"/>
<SubTexture name="gun04.png" x="827" y="84" width="16" height="41"/>
<SubTexture name="gun05.png" x="423" y="0" width="21" height="41"/>
<SubTexture name="gun06.png" x="810" y="900" width="17" height="38"/>
<SubTexture name="gun07.png" x="829" y="796" width="14" height="41"/>
<SubTexture name="gun08.png" x="848" y="263" width="10" height="47"/>
<SubTexture name="gun09.png" x="809" y="611" width="20" height="52"/>
<SubTexture name="gun10.png" x="808" y="961" width="20" height="52"/>
<SubTexture name="laserBlue01.png" x="856" y="421" width="9" height="54"/>
<SubTexture name="laserBlue02.png" x="841" y="647" width="13" height="37"/>
<SubTexture name="laserBlue03.png" x="856" y="57" width="9" height="37"/>
<SubTexture name="laserBlue04.png" x="835" y="565" width="13" height="37"/>
<SubTexture name="laserBlue05.png" x="858" y="475" width="9" height="37"/>
<SubTexture name="laserBlue06.png" x="835" y="752" width="13" height="37"/>
<SubTexture name="laserBlue07.png" x="856" y="775" width="9" height="37"/>
<SubTexture name="laserBlue08.png" x="596" y="961" width="48" height="46"/>
<SubTexture name="laserBlue09.png" x="434" y="325" width="48" height="46"/>
<SubTexture name="laserBlue10.png" x="740" y="724" width="37" height="37"/>
<SubTexture name="laserBlue11.png" x="698" y="795" width="38" height="37"/>
<SubTexture name="laserBlue12.png" x="835" y="695" width="13" height="57"/>
<SubTexture name="laserBlue13.png" x="856" y="869" width="9" height="57"/>
<SubTexture name="laserBlue14.png" x="842" y="206" width="13" height="57"/>
<SubTexture name="laserBlue15.png" x="849" y="480" width="9" height="57"/>
<SubTexture name="laserBlue16.png" x="843" y="62" width="13" height="54"/>
<SubTexture name="laserGreen01.png" x="740" y="686" width="37" height="38"/>
<SubTexture name="laserGreen02.png" x="843" y="116" width="13" height="57"/>
<SubTexture name="laserGreen03.png" x="855" y="173" width="9" height="57"/>
<SubTexture name="laserGreen04.png" x="848" y="565" width="13" height="37"/>
<SubTexture name="laserGreen05.png" x="854" y="639" width="9" height="37"/>
<SubTexture name="laserGreen06.png" x="845" y="0" width="13" height="57"/>
<SubTexture name="laserGreen07.png" x="849" y="364" width="9" height="57"/>
<SubTexture name="laserGreen08.png" x="848" y="738" width="13" height="37"/>
<SubTexture name="laserGreen09.png" x="856" y="94" width="9" height="37"/>
<SubTexture name="laserGreen10.png" x="843" y="426" width="13" height="54"/>
<SubTexture name="laserGreen11.png" x="849" y="310" width="9" height="54"/>
<SubTexture name="laserGreen12.png" x="843" y="602" width="13" height="37"/>
<SubTexture name="laserGreen13.png" x="858" y="0" width="9" height="37"/>
<SubTexture name="laserGreen14.png" x="193" y="240" width="48" height="46"/>
<SubTexture name="laserGreen15.png" x="443" y="182" width="48" height="46"/>
<SubTexture name="laserGreen16.png" x="760" y="192" width="37" height="37"/>
<SubTexture name="laserRed01.png" x="858" y="230" width="9" height="54"/>
<SubTexture name="laserRed02.png" x="843" y="977" width="13" height="37"/>
<SubTexture name="laserRed03.png" x="856" y="602" width="9" height="37"/>
<SubTexture name="laserRed04.png" x="843" y="940" width="13" height="37"/>
<SubTexture name="laserRed05.png" x="856" y="983" width="9" height="37"/>
<SubTexture name="laserRed06.png" x="843" y="903" width="13" height="37"/>
<SubTexture name="laserRed07.png" x="856" y="131" width="9" height="37"/>
<SubTexture name="laserRed08.png" x="580" y="661" width="48" height="46"/>
<SubTexture name="laserRed09.png" x="602" y="600" width="48" height="46"/>
<SubTexture name="laserRed10.png" x="738" y="650" width="37" height="36"/>
<SubTexture name="laserRed11.png" x="737" y="613" width="37" height="37"/>
<SubTexture name="laserRed12.png" x="843" y="846" width="13" height="57"/>
<SubTexture name="laserRed13.png" x="856" y="812" width="9" height="57"/>
<SubTexture name="laserRed14.png" x="843" y="789" width="13" height="57"/>
<SubTexture name="laserRed15.png" x="856" y="926" width="9" height="57"/>
<SubTexture name="laserRed16.png" x="848" y="684" width="13" height="54"/>
<SubTexture name="meteorBrown_big1.png" x="224" y="664" width="101" height="84"/>
<SubTexture name="meteorBrown_big2.png" x="0" y="520" width="120" height="98"/>
<SubTexture name="meteorBrown_big3.png" x="518" y="810" width="89" height="82"/>
<SubTexture name="meteorBrown_big4.png" x="327" y="452" width="98" height="96"/>
<SubTexture name="meteorBrown_med1.png" x="651" y="447" width="43" height="43"/>
<SubTexture name="meteorBrown_med3.png" x="237" y="452" width="45" height="40"/>
<SubTexture name="meteorBrown_small1.png" x="406" y="234" width="28" height="28"/>
<SubTexture name="meteorBrown_small2.png" x="778" y="587" width="29" height="26"/>
<SubTexture name="meteorBrown_tiny1.png" x="346" y="814" width="18" height="18"/>
<SubTexture name="meteorBrown_tiny2.png" x="399" y="814" width="16" height="15"/>
<SubTexture name="meteorGrey_big1.png" x="224" y="748" width="101" height="84"/>
<SubTexture name="meteorGrey_big2.png" x="0" y="618" width="120" height="98"/>
<SubTexture name="meteorGrey_big3.png" x="516" y="728" width="89" height="82"/>
<SubTexture name="meteorGrey_big4.png" x="327" y="548" width="98" height="96"/>
<SubTexture name="meteorGrey_med1.png" x="674" y="219" width="43" height="43"/>
<SubTexture name="meteorGrey_med2.png" x="282" y="452" width="45" height="40"/>
<SubTexture name="meteorGrey_small1.png" x="406" y="262" width="28" height="28"/>
<SubTexture name="meteorGrey_small2.png" x="396" y="413" width="29" height="26"/>
<SubTexture name="meteorGrey_tiny1.png" x="364" y="814" width="18" height="18"/>
<SubTexture name="meteorGrey_tiny2.png" x="602" y="646" width="16" height="15"/>
<SubTexture name="numeral0.png" x="367" y="644" width="19" height="19"/>
<SubTexture name="numeral1.png" x="205" y="688" width="19" height="19"/>
<SubTexture name="numeral2.png" x="406" y="290" width="19" height="19"/>
<SubTexture name="numeral3.png" x="580" y="707" width="19" height="19"/>
<SubTexture name="numeral4.png" x="386" y="644" width="19" height="19"/>
<SubTexture name="numeral5.png" x="628" y="646" width="19" height="19"/>
<SubTexture name="numeral6.png" x="671" y="1002" width="19" height="19"/>
<SubTexture name="numeral7.png" x="690" y="1004" width="19" height="19"/>
<SubTexture name="numeral8.png" x="709" y="1004" width="19" height="19"/>
<SubTexture name="numeral9.png" x="491" y="215" width="19" height="19"/>
<SubTexture name="numeralX.png" x="382" y="814" width="17" height="17"/>
<SubTexture name="pill_blue.png" x="674" y="262" width="22" height="21"/>
<SubTexture name="pill_green.png" x="573" y="989" width="22" height="21"/>
<SubTexture name="pill_red.png" x="222" y="108" width="22" height="21"/>
<SubTexture name="pill_yellow.png" x="222" y="129" width="22" height="21"/>
<SubTexture name="playerLife1_blue.png" x="482" y="358" width="33" height="26"/>
<SubTexture name="playerLife1_green.png" x="535" y="150" width="33" height="26"/>
<SubTexture name="playerLife1_orange.png" x="777" y="327" width="33" height="26"/>
<SubTexture name="playerLife1_red.png" x="775" y="301" width="33" height="26"/>
<SubTexture name="playerLife2_blue.png" x="465" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_green.png" x="391" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_orange.png" x="428" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_red.png" x="502" y="991" width="37" height="26"/>
<SubTexture name="playerLife3_blue.png" x="777" y="385" width="32" height="26"/>
<SubTexture name="playerLife3_green.png" x="778" y="469" width="32" height="26"/>
<SubTexture name="playerLife3_orange.png" x="777" y="712" width="32" height="26"/>
<SubTexture name="playerLife3_red.png" x="777" y="443" width="32" height="26"/>
<SubTexture name="playerShip1_blue.png" x="211" y="941" width="99" height="75"/>
<SubTexture name="playerShip1_damage1.png" x="112" y="941" width="99" height="75"/>
<SubTexture name="playerShip1_damage2.png" x="247" y="234" width="99" height="75"/>
<SubTexture name="playerShip1_damage3.png" x="247" y="159" width="99" height="75"/>
<SubTexture name="playerShip1_green.png" x="237" y="377" width="99" height="75"/>
<SubTexture name="playerShip1_orange.png" x="247" y="84" width="99" height="75"/>
<SubTexture name="playerShip1_red.png" x="224" y="832" width="99" height="75"/>
<SubTexture name="playerShip2_blue.png" x="112" y="791" width="112" height="75"/>
<SubTexture name="playerShip2_damage1.png" x="0" y="866" width="112" height="75"/>
<SubTexture name="playerShip2_damage2.png" x="0" y="791" width="112" height="75"/>
<SubTexture name="playerShip2_damage3.png" x="0" y="716" width="112" height="75"/>
<SubTexture name="playerShip2_green.png" x="112" y="866" width="112" height="75"/>
<SubTexture name="playerShip2_orange.png" x="112" y="716" width="112" height="75"/>
<SubTexture name="playerShip2_red.png" x="0" y="941" width="112" height="75"/>
<SubTexture name="playerShip3_blue.png" x="325" y="739" width="98" height="75"/>
<SubTexture name="playerShip3_damage1.png" x="323" y="832" width="98" height="75"/>
<SubTexture name="playerShip3_damage2.png" x="310" y="907" width="98" height="75"/>
<SubTexture name="playerShip3_damage3.png" x="325" y="664" width="98" height="75"/>
<SubTexture name="playerShip3_green.png" x="346" y="75" width="98" height="75"/>
<SubTexture name="playerShip3_orange.png" x="336" y="309" width="98" height="75"/>
<SubTexture name="playerShip3_red.png" x="325" y="0" width="98" height="75"/>
<SubTexture name="powerupBlue.png" x="696" y="329" width="34" height="33"/>
<SubTexture name="powerupBlue_bolt.png" x="539" y="989" width="34" height="33"/>
<SubTexture name="powerupBlue_shield.png" x="777" y="679" width="34" height="33"/>
<SubTexture name="powerupBlue_star.png" x="776" y="895" width="34" height="33"/>
<SubTexture name="powerupGreen.png" x="774" y="613" width="34" height="33"/>
<SubTexture name="powerupGreen_bolt.png" x="766" y="80" width="34" height="33"/>
<SubTexture name="powerupGreen_shield.png" x="776" y="862" width="34" height="33"/>
<SubTexture name="powerupGreen_star.png" x="651" y="490" width="34" height="33"/>
<SubTexture name="powerupRed.png" x="491" y="182" width="34" height="33"/>
<SubTexture name="powerupRed_bolt.png" x="775" y="646" width="34" height="33"/>
<SubTexture name="powerupRed_shield.png" x="776" y="928" width="34" height="33"/>
<SubTexture name="powerupRed_star.png" x="774" y="977" width="34" height="33"/>
<SubTexture name="powerupYellow.png" x="774" y="761" width="34" height="33"/>
<SubTexture name="powerupYellow_bolt.png" x="740" y="761" width="34" height="33"/>
<SubTexture name="powerupYellow_shield.png" x="482" y="325" width="34" height="33"/>
<SubTexture name="powerupYellow_star.png" x="607" y="857" width="34" height="33"/>
<SubTexture name="scratch1.png" x="325" y="814" width="21" height="16"/>
<SubTexture name="scratch2.png" x="423" y="41" width="21" height="16"/>
<SubTexture name="scratch3.png" x="346" y="295" width="16" height="12"/>
<SubTexture name="shield1.png" x="0" y="412" width="133" height="108"/>
<SubTexture name="shield2.png" x="0" y="293" width="143" height="119"/>
<SubTexture name="shield3.png" x="0" y="156" width="144" height="137"/>
<SubTexture name="shield_bronze.png" x="797" y="143" width="30" height="30"/>
<SubTexture name="shield_gold.png" x="797" y="113" width="30" height="30"/>
<SubTexture name="shield_silver.png" x="778" y="824" width="30" height="30"/>
<SubTexture name="speed.png" x="858" y="284" width="7" height="108"/>
<SubTexture name="star1.png" x="628" y="681" width="25" height="24"/>
<SubTexture name="star2.png" x="222" y="84" width="25" height="24"/>
<SubTexture name="star3.png" x="576" y="300" width="24" height="24"/>
<SubTexture name="star_bronze.png" x="778" y="794" width="31" height="30"/>
<SubTexture name="star_gold.png" x="778" y="557" width="31" height="30"/>
<SubTexture name="star_silver.png" x="778" y="527" width="31" height="30"/>
<SubTexture name="things_bronze.png" x="778" y="495" width="32" height="32"/>
<SubTexture name="things_gold.png" x="777" y="411" width="32" height="32"/>
<SubTexture name="things_silver.png" x="777" y="353" width="32" height="32"/>
<SubTexture name="turretBase_big.png" x="310" y="982" width="41" height="41"/>
<SubTexture name="turretBase_small.png" x="808" y="301" width="26" height="26"/>
<SubTexture name="ufoBlue.png" x="444" y="91" width="91" height="91"/>
<SubTexture name="ufoGreen.png" x="434" y="234" width="91" height="91"/>
<SubTexture name="ufoRed.png" x="444" y="0" width="91" height="91"/>
<SubTexture name="ufoYellow.png" x="505" y="898" width="91" height="91"/>
<SubTexture name="wingBlue_0.png" x="647" y="924" width="45" height="78"/>
<SubTexture name="wingBlue_1.png" x="738" y="253" width="37" height="72"/>
<SubTexture name="wingBlue_2.png" x="805" y="0" width="26" height="84"/>
<SubTexture name="wingBlue_3.png" x="600" y="450" width="51" height="75"/>
<SubTexture name="wingBlue_4.png" x="692" y="924" width="42" height="80"/>
<SubTexture name="wingBlue_5.png" x="596" y="892" width="51" height="69"/>
<SubTexture name="wingBlue_6.png" x="694" y="847" width="42" height="74"/>
<SubTexture name="wingBlue_7.png" x="675" y="134" width="43" height="83"/>
<SubTexture name="wingGreen_0.png" x="650" y="525" width="45" height="78"/>
<SubTexture name="wingGreen_1.png" x="775" y="229" width="37" height="72"/>
<SubTexture name="wingGreen_2.png" x="809" y="527" width="26" height="84"/>
<SubTexture name="wingGreen_3.png" x="535" y="0" width="51" height="75"/>
<SubTexture name="wingGreen_4.png" x="694" y="431" width="42" height="80"/>
<SubTexture name="wingGreen_5.png" x="525" y="251" width="51" height="69"/>
<SubTexture name="wingGreen_6.png" x="695" y="511" width="42" height="74"/>
<SubTexture name="wingGreen_7.png" x="655" y="764" width="43" height="83"/>
<SubTexture name="wingRed_0.png" x="809" y="712" width="26" height="84"/>
<SubTexture name="wingRed_1.png" x="768" y="0" width="37" height="72"/>
<SubTexture name="wingRed_2.png" x="600" y="300" width="51" height="75"/>
<SubTexture name="wingRed_3.png" x="698" y="715" width="42" height="80"/>
<SubTexture name="wingRed_4.png" x="586" y="75" width="51" height="69"/>
<SubTexture name="wingRed_5.png" x="718" y="123" width="42" height="74"/>
<SubTexture name="wingRed_6.png" x="653" y="681" width="43" height="83"/>
<SubTexture name="wingRed_7.png" x="651" y="286" width="45" height="78"/>
<SubTexture name="wingYellow_0.png" x="650" y="603" width="45" height="78"/>
<SubTexture name="wingYellow_1.png" x="760" y="120" width="37" height="72"/>
<SubTexture name="wingYellow_2.png" x="809" y="353" width="26" height="84"/>
<SubTexture name="wingYellow_3.png" x="576" y="150" width="51" height="75"/>
<SubTexture name="wingYellow_4.png" x="726" y="0" width="42" height="80"/>
<SubTexture name="wingYellow_5.png" x="525" y="182" width="51" height="69"/>
<SubTexture name="wingYellow_6.png" x="695" y="585" width="42" height="74"/>
<SubTexture name="wingYellow_7.png" x="651" y="364" width="43" height="83"/>
</TextureAtlas>
Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Some files were not shown because too many files have changed in this diff Show More