diff --git a/public/index.html b/public/index.html
index bc97ca1..ca71948 100644
--- a/public/index.html
+++ b/public/index.html
@@ -175,6 +175,10 @@
background: rgba(255, 193, 7, 0.12);
color: var(--yellow);
}
+ .badge-bake {
+ background: rgba(46, 204, 113, 0.1);
+ color: var(--green);
+ }
.card-url-preview {
font-size: 13px;
color: var(--muted);
@@ -314,6 +318,21 @@
cursor: pointer;
accent-color: var(--accent);
}
+ .bake-toggle {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+ }
+ .bake-toggle input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+ accent-color: var(--green);
+ }
.hint {
font-size: 12px;
color: var(--muted);
@@ -716,11 +735,13 @@
.map((v) => {
const viewUrl = origin + "/v/" + v.id;
const urlPreview = v.urls.map((u, i) =>
- `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}`
+ `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
).join(" → ");
+ const anyBake = v.urls.some((u) => u.bakeStyles);
const badges = [
`${v.method.toUpperCase()}`,
v.metaRefreshEnabled ? 'META-REFRESH' : "",
+ anyBake ? 'BAKE' : "",
]
.filter(Boolean)
.join(" ");
@@ -812,7 +833,7 @@
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
let urlRowsHTML = urls
- .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode))
+ .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles))
.join("");
const method = existing ? existing.method : "ssr";
const metaChecked = existing ? existing.metaRefreshEnabled : false;
@@ -820,8 +841,6 @@
const cacheTtl = existing ? existing.cacheTtlSec : 60;
const vpW = existing ? existing.viewportWidth : 1920;
const vpH = existing ? existing.viewportHeight : 1080;
- const bakeStylesChecked = existing ? existing.bakeStyles : false;
- const bakeSectionStyle = method === "ssr" ? "" : "display:none;";
return `
${title}
@@ -855,14 +874,6 @@
TV reloads when rotation advances. Required for multi-URL on non-JS browsers.
-
-
-
-
-
-
Fixes styling issues on browsers with limited CSS support (e.g., DuckDuckGo). Increases HTML size.
-
-
@@ -889,11 +900,12 @@
`;
}
- function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) {
+ function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) {
const isSingle = total <= 1;
const disabledAttr = isSingle ? "disabled" : "";
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
const darkChecked = forceDarkMode ? "checked" : "";
+ const bakeChecked = bakeStyles ? "checked" : "";
return `
@@ -902,6 +914,10 @@
🌙
+
`;
}
@@ -914,7 +930,7 @@
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const div = document.createElement("div");
- div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false);
+ div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false);
container.appendChild(div.firstElementChild);
updateUrlRowStates();
}
@@ -946,7 +962,6 @@
function onMethodChange() {
const method = document.getElementById("view-method").value;
document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none";
- document.getElementById("bake-section").style.display = method === "ssr" ? "" : "none";
}
// ═══════════════════════════════════════════════
@@ -962,7 +977,6 @@
const name = document.getElementById("view-name").value.trim();
const method = document.getElementById("view-method").value;
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
- const bakeStyles = document.getElementById("bake-styles")?.checked ?? false;
const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60;
const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920;
const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080;
@@ -972,10 +986,12 @@
const urlVal = row.querySelector(".url-input").value.trim();
if (!urlVal) continue;
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
+ const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
urls.push({
url: urlVal,
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
forceDarkMode,
+ bakeStyles,
});
}
@@ -988,11 +1004,7 @@
return;
}
- const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight };
- if (method === "ssr") {
- body.metaRefreshEnabled = metaRefreshEnabled;
- body.bakeStyles = bakeStyles;
- }
+ const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled };
try {
if (isEdit) {
@@ -1049,6 +1061,7 @@
debugSocket.close();
debugSocket = null;
}
+ if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; }
}
function setDebugInterval(ms) {
diff --git a/src/server.ts b/src/server.ts
index c8f74a0..8bce0db 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -26,6 +26,7 @@ interface URLItem {
url: string;
durationSec: number;
forceDarkMode?: boolean;
+ bakeStyles?: boolean;
}
interface View {
@@ -37,7 +38,6 @@ interface View {
cacheTtlSec: number;
viewportWidth: number;
viewportHeight: number;
- bakeStyles: boolean;
createdAt: number;
}
@@ -50,7 +50,6 @@ interface ViewRow {
cache_ttl_sec: number;
viewport_width: number;
viewport_height: number;
- bake_styles: number;
created_at: number;
}
@@ -62,7 +61,6 @@ interface CreateViewBody {
cacheTtlSec?: number;
viewportWidth?: number;
viewportHeight?: number;
- bakeStyles?: boolean;
}
type UpdateViewBody = Partial
;
@@ -141,7 +139,6 @@ function rowToView(row: ViewRow): View {
cacheTtlSec: row.cache_ttl_sec ?? 60,
viewportWidth: row.viewport_width ?? 1920,
viewportHeight: row.viewport_height ?? 1080,
- bakeStyles: row.bake_styles === 1,
createdAt: row.created_at,
};
}
@@ -159,8 +156,8 @@ function dbLoad(id: string): View | null {
function dbInsert(v: View): void {
db.run(
- `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,bake_styles,created_at)
- VALUES (?,?,?,?,?,?,?,?,?,?)`,
+ `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
+ VALUES (?,?,?,?,?,?,?,?,?)`,
[
v.id,
v.name,
@@ -170,7 +167,6 @@ function dbInsert(v: View): void {
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
- v.bakeStyles ? 1 : 0,
v.createdAt,
],
);
@@ -178,7 +174,7 @@ function dbInsert(v: View): void {
function dbUpdate(v: View): void {
db.run(
- "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=?,bake_styles=? WHERE id=?",
+ "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?",
[
v.name,
JSON.stringify(v.urls),
@@ -187,7 +183,6 @@ function dbUpdate(v: View): void {
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
- v.bakeStyles ? 1 : 0,
v.id,
],
);
@@ -521,9 +516,11 @@ function drainRenderQueue(): void {
inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex });
renderPromise.then(
(body) => {
+ inFlightRenders.delete(next.key);
next.resolve(body);
},
(err) => {
+ inFlightRenders.delete(next.key);
next.reject(err);
},
);
@@ -533,7 +530,7 @@ function drainRenderQueue(): void {
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise {
const urlItem = view.urls[urlIndex];
const forceDarkMode = urlItem.forceDarkMode ?? false;
- const bakeStyles = view.bakeStyles ?? false;
+ const bakeStyles = urlItem.bakeStyles ?? false;
let body: string;
if (view.method === "mjpeg") {
@@ -620,7 +617,7 @@ function cacheStats(): Array<{
}> = [];
for (const [key, entry] of renderCache) {
- const [viewId, idxStr] = key.split(":");
+ const [viewId] = key.split(":");
const view = viewCacheGet(viewId);
result.push({
key,
@@ -729,15 +726,11 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
/**
* Bake computed styles into inline style attributes.
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
- * Only bakes styles that differ from the element's default computed styles.
+ * Compares each element against a same-tagname fresh element so tag-specific defaults
+ * (e.g. block display on div, inline on span, bold on h1) are accounted for.
*/
async function bakeComputedStyles(page: Page): Promise {
await page.evaluate((): void => {
- // Create a dummy element to get default styles for comparison
- const dummy = document.createElement("div");
- dummy.style.cssText = "position:absolute;visibility:hidden;";
- document.body.appendChild(dummy);
-
const computedProperties = [
// Layout
"display", "position", "top", "right", "bottom", "left",
@@ -749,64 +742,86 @@ async function bakeComputedStyles(page: Page): Promise {
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
// Box model
- "box-sizing", "border", "border-radius", "border-top", "border-right", "border-bottom", "border-left",
- "border-width", "border-style", "border-color",
+ "box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
+ "border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
+ "border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
+ "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
// Colors & Background
- "color", "background", "background-color", "background-image", "background-size", "background-position",
- "background-repeat", "background-attachment",
+ "color", "background-color", "background-image", "background-size", "background-position",
+ "background-repeat", "background-attachment", "background-clip", "background-origin",
// Typography
- "font", "font-family", "font-size", "font-weight", "font-style", "font-variant",
- "line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing",
+ "font-family", "font-size", "font-weight", "font-style", "font-variant",
+ "line-height", "text-align", "text-decoration-line", "text-decoration-color",
+ "text-transform", "letter-spacing", "word-spacing",
"white-space", "overflow-wrap", "word-break",
// Visual
- "opacity", "visibility", "z-index", "overflow", "overflow-x", "overflow-y",
+ "opacity", "visibility", "z-index", "overflow-x", "overflow-y",
"box-shadow", "text-shadow", "transform", "transform-origin",
// List/Table
- "list-style", "border-collapse", "border-spacing",
+ "list-style-type", "list-style-position", "border-collapse", "border-spacing",
+ // Cursor/pointer
+ "cursor", "pointer-events",
];
- function getDefaultValue(prop: string): string {
- return window.getComputedStyle(dummy).getPropertyValue(prop);
+ // Cache of default computed values keyed by tagName
+ const defaultCache = new Map>();
+ const hiddenContainer = document.createElement("div");
+ hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
+ document.body.appendChild(hiddenContainer);
+
+ function getTagDefaults(tag: string): Map {
+ let cache = defaultCache.get(tag);
+ if (cache) return cache;
+ cache = new Map();
+ try {
+ const el = document.createElement(tag);
+ hiddenContainer.appendChild(el);
+ const cs = window.getComputedStyle(el);
+ for (const prop of computedProperties) {
+ cache.set(prop, cs.getPropertyValue(prop));
+ }
+ el.remove();
+ } catch { /* unknown tag, cache will be empty */ }
+ defaultCache.set(tag, cache);
+ return cache;
}
- function shouldBake(prop: string, value: string): boolean {
- if (!value || value === "none" || value === "auto" || value === "normal") return false;
- if (value === "0px" || value === "0" || value === "rgba(0, 0, 0, 0)") return false;
- if (value.includes("initial") || value.includes("inherit")) return false;
- return value !== getDefaultValue(prop);
+ function shouldBake(value: string, defaultValue: string): boolean {
+ if (!value) return false;
+ // Skip browser keywords that cannot be serialized as inline style values
+ if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false;
+ return value !== defaultValue;
}
// Walk all elements and bake computed styles
const elements = document.querySelectorAll("*");
for (const el of elements) {
- // Skip script, style, meta, link tags
+ if (el === hiddenContainer) continue;
+ // Skip non-visual tags
const tag = el.tagName.toLowerCase();
- if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue;
+ if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue;
const computed = window.getComputedStyle(el);
+ const defaults = getTagDefaults(tag);
const stylesToBake: string[] = [];
for (const prop of computedProperties) {
const value = computed.getPropertyValue(prop);
- if (shouldBake(prop, value)) {
+ const defaultValue = defaults.get(prop) ?? "";
+ if (shouldBake(value, defaultValue)) {
stylesToBake.push(`${prop}:${value}`);
}
}
if (stylesToBake.length > 0) {
- // Merge with existing inline styles, preserving user-set inline styles
+ // Existing inline styles take precedence — put them after baked styles
const existingInline = el.getAttribute("style") || "";
const bakedStyles = stylesToBake.join(";");
- if (existingInline) {
- // User inline styles take precedence - put them last
- el.setAttribute("style", `${bakedStyles};${existingInline}`);
- } else {
- el.setAttribute("style", bakedStyles);
- }
+ el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
}
}
- dummy.remove();
+ hiddenContainer.remove();
});
}
@@ -1169,13 +1184,13 @@ async function handleAPI(req: Request, path: string): Promise {
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
forceDarkMode: u.forceDarkMode ?? false,
+ bakeStyles: u.bakeStyles ?? false,
})),
method: body.method,
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
- bakeStyles: body.bakeStyles ?? false,
createdAt: Math.floor(Date.now() / 1000),
};
@@ -1212,6 +1227,7 @@ async function handleAPI(req: Request, path: string): Promise {
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
forceDarkMode: u.forceDarkMode ?? false,
+ bakeStyles: u.bakeStyles ?? false,
}));
}
if (body.method !== undefined) {
@@ -1226,7 +1242,6 @@ async function handleAPI(req: Request, path: string): Promise {
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
if (body.viewportHeight !== undefined)
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
- if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles;
dbUpdate(view);
cacheInvalidate(viewId);
@@ -1258,6 +1273,7 @@ const STATIC_DIR = new URL("../public", import.meta.url).pathname;
async function serveStatic(path: string): Promise {
const filePath = path === "/" ? "/index.html" : path;
+ if (!filePath.startsWith("/") || filePath.includes("..")) return null;
try {
const file = Bun.file(STATIC_DIR + filePath);
await file.slice(0, 0).text();