Add controller icon PNGs and tinting logic

Add per-platform controller PNGs and runtime tinting, hook them into prompts, and update deploy/stage scripts.

- Add many controller PNG assets under ui/Icons and copy them into browser/assets/icons on deploy/stage.
- New controller-glyphs.js: image-based glyph renderer that tints white pixels to the UI color via a canvas pixel pass, caches results, and supports platform selection.
- New controller-icons.js: adapter that forwards rendering to controller-glyphs.
- Update controller-prompts.js to render icon glyphs (and include LB/RB tab-switch prompts) and call hydrate after rendering.
- Call CO_ControllerGlyphs.onThemeApplied from theme.js so glyphs re-tint when theme changes.
- Include new scripts in index.html and add CSS rules for glyph image sizing in styles.css.
- Update docs/dev-log.md describing the change and testing/next steps.

This change replaces previous inline/SVG glyph handling with individual PNG icons and adds deploy-time copying so the repo keeps a single source for controller icons.
This commit is contained in:
2026-06-18 15:41:46 +12:00
parent 1353e5a52c
commit 102acd3340
55 changed files with 407 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,223 @@
(function () {
"use strict";
var KEY_ALIASES = {
LB: "lb",
RB: "rb",
A: "a",
B: "b",
X: "x",
Y: "y"
};
var GLYPH_SIZES = {
a: { w: 28, h: 28 },
b: { w: 28, h: 28 },
x: { w: 28, h: 28 },
y: { w: 28, h: 28 },
lb: { w: 34, h: 34 },
rb: { w: 34, h: 34 }
};
var PLATFORMS = {
Xbox: {
base: "./assets/icons/Xbox/",
files: {
a: "T_X_A_White_Alt.png",
b: "T_X_B_White_Alt.png",
x: "T_X_X_White_Alt.png",
y: "T_X_Y_White_Alt.png",
lb: "T_X_LB_Alt.png",
rb: "T_X_RB_Alt.png"
}
},
Playstation: {
base: "./assets/icons/Playstation/",
files: {
a: "T_P5_Cross_Alt.png",
b: "T_P5_Circle_Alt.png",
x: "T_P5_Square_Alt.png",
y: "T_P5_Triangle_Alt.png",
lb: "T_P5_L1_Alt.png",
rb: "T_P5_R1_Alt.png"
}
},
Switch: {
base: "./assets/icons/Switch/",
files: {
a: "T_S_A_Alt.png",
b: "T_S_B_Alt.png",
x: "T_S_X_Alt.png",
y: "T_S_Y_Alt.png",
lb: "T_S_LB_Alt.png",
rb: "T_S_RB_Alt.png"
}
}
};
var activePlatform = "Xbox";
var tintCache = {};
var WHITE_THRESHOLD = 200;
var BLACK_THRESHOLD = 40;
function glyphUrl(glyphId) {
var platform = PLATFORMS[activePlatform] || PLATFORMS.Xbox;
var file = platform.files[glyphId];
if (!file) {
return null;
}
return platform.base + file;
}
function getTintColor() {
var style = getComputedStyle(document.documentElement);
return {
r: parseInt(style.getPropertyValue("--co-r"), 10) || 18,
g: parseInt(style.getPropertyValue("--co-g"), 10) || 255,
b: parseInt(style.getPropertyValue("--co-b"), 10) || 21
};
}
function tintCacheKey(url, color) {
return url + "|" + color.r + "," + color.g + "," + color.b;
}
function classifyPixel(r, g, b) {
if (r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD) {
return "white";
}
if (r <= BLACK_THRESHOLD && g <= BLACK_THRESHOLD && b <= BLACK_THRESHOLD) {
return "black";
}
return "edge";
}
function tintImageData(imageData, color) {
var data = imageData.data;
var tr = color.r;
var tg = color.g;
var tb = color.b;
for (var i = 0; i < data.length; i += 4) {
var alpha = data[i + 3];
if (alpha < 4) {
data[i + 3] = 0;
continue;
}
var r = data[i];
var g = data[i + 1];
var b = data[i + 2];
var kind = classifyPixel(r, g, b);
if (kind === "white") {
data[i] = tr;
data[i + 1] = tg;
data[i + 2] = tb;
} else if (kind === "black") {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
} else {
var lum = (r + g + b) / (3 * 255);
data[i] = Math.round(lum * tr);
data[i + 1] = Math.round(lum * tg);
data[i + 2] = Math.round(lum * tb);
}
}
return imageData;
}
function tintImage(url, color, done) {
var key = tintCacheKey(url, color);
if (tintCache[key]) {
done(tintCache[key]);
return;
}
var img = new Image();
img.onload = function () {
var canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
var ctx = canvas.getContext("2d");
if (!ctx) {
done(null);
return;
}
ctx.drawImage(img, 0, 0);
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
ctx.putImageData(tintImageData(imageData, color), 0, 0);
var dataUrl = canvas.toDataURL("image/png");
tintCache[key] = dataUrl;
done(dataUrl);
};
img.onerror = function () {
done(null);
};
img.src = url;
}
function hydrateGlyphs(root) {
var scope = root || document;
var color = getTintColor();
var imgs = scope.querySelectorAll(".co-glyph-img[data-glyph-src]");
for (var i = 0; i < imgs.length; i++) {
(function (img) {
var src = img.getAttribute("data-glyph-src");
if (!src) {
return;
}
tintImage(src, color, function (dataUrl) {
if (dataUrl) {
img.src = dataUrl;
}
});
})(imgs[i]);
}
}
window.CO_ControllerGlyphs = {
setPlatform: function (platformName) {
if (PLATFORMS[platformName]) {
activePlatform = platformName;
tintCache = {};
}
},
getPlatform: function () {
return activePlatform;
},
hydrate: hydrateGlyphs,
onThemeApplied: function () {
tintCache = {};
hydrateGlyphs(document);
},
render: function (key) {
var glyphId = KEY_ALIASES[String(key || "").toUpperCase()];
if (!glyphId) {
return '<span class="co-prompt-key co-prompt-key--fallback">' + key + "</span>";
}
var size = GLYPH_SIZES[glyphId] || GLYPH_SIZES.a;
var url = glyphUrl(glyphId);
if (!url) {
return '<span class="co-prompt-key co-prompt-key--fallback">' + key + "</span>";
}
return (
'<span class="co-prompt-key co-prompt-key--glyph co-prompt-key--' + glyphId + '"' +
' style="width:' + size.w + "px;height:" + size.h + 'px"' +
' aria-hidden="true">' +
'<img class="co-glyph-img" data-glyph-src="' + url + '" src="' + url + '" alt="" draggable="false" />' +
"</span>"
);
}
};
})();
@@ -0,0 +1,12 @@
(function () {
"use strict";
window.CO_ControllerIcons = {
render: function (key) {
if (window.CO_ControllerGlyphs && CO_ControllerGlyphs.render) {
return CO_ControllerGlyphs.render(key);
}
return '<span class="co-prompt-key co-prompt-key--fallback">' + key + "</span>";
}
};
})();
@@ -1,6 +1,18 @@
(function () {
"use strict";
var TAB_SWITCH_PROMPTS = [
{ key: "LB", label: "Prev Tab" },
{ key: "RB", label: "Next Tab" }
];
function renderIcon(key) {
if (window.CO_ControllerIcons && CO_ControllerIcons.render) {
return CO_ControllerIcons.render(key);
}
return '<span class="co-prompt-key">' + key + "</span>";
}
window.CO_ControllerPrompts = {
render: function (root, state) {
var el = root.querySelector(".co-prompts");
@@ -10,11 +22,15 @@
el.innerHTML = prompts.map(function (p) {
return (
'<div class="co-prompt">' +
'<span class="co-prompt-key">' + p.key + "</span>" +
renderIcon(p.key) +
"<span>" + p.label + "</span>" +
"</div>"
);
}).join("");
if (window.CO_ControllerGlyphs && CO_ControllerGlyphs.hydrate) {
CO_ControllerGlyphs.hydrate(el);
}
},
getPrompts: function (state) {
@@ -29,34 +45,32 @@
}
if (state.activeTab === "direct-connect") {
return [
return TAB_SWITCH_PROMPTS.concat([
{ key: "A", label: "Connect" },
{ key: "B", label: "Back" }
];
]);
}
if (state.focusZone === "filters") {
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
return [
return TAB_SWITCH_PROMPTS.concat([
{ key: "A", label: isReset ? "Reset" : "Change" },
{ key: "B", label: "Back" }
];
]);
}
if (state.focusZone === "tabs") {
return [
{ key: "LB", label: "Prev Tab" },
{ key: "RB", label: "Next Tab" },
return TAB_SWITCH_PROMPTS.concat([
{ key: "B", label: "Back" }
];
]);
}
return [
return TAB_SWITCH_PROMPTS.concat([
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
]);
}
};
})();
@@ -45,6 +45,10 @@
applyHudRgb(root, "--co-bg", theme.hudBackground);
applyCursorTint(root, hud);
if (window.CO_ControllerGlyphs && CO_ControllerGlyphs.onThemeApplied) {
CO_ControllerGlyphs.onThemeApplied();
}
console.log(
"[CO] Applied HUD theme rgb(" +
clampByte(hud.r) + "," +
@@ -85,6 +85,8 @@
<script src="./components/filters.js"></script>
<script src="./components/server-list.js"></script>
<script src="./components/server-details.js"></script>
<script src="./components/controller-glyphs.js"></script>
<script src="./components/controller-icons.js"></script>
<script src="./components/controller-prompts.js"></script>
<script src="./components/direct-connect.js"></script>
<script src="./components/hover.js"></script>
@@ -626,6 +626,23 @@ body {
padding: 0 3px;
}
.co-prompt-key--glyph {
position: relative;
display: inline-block;
min-width: 0;
border: none;
border-radius: 0;
padding: 0;
flex-shrink: 0;
vertical-align: middle;
}
.co-prompt-key--glyph .co-glyph-img {
width: 100%;
height: 100%;
display: block;
}
.co-direct-connect {
padding: 16px 20px;
max-width: 480px;