mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
Merge pull request #704 from PR0M3TH3AN/codex/implement-tombstone-management-for-nostrclient
Add tombstone guards to nostr video cache
This commit is contained in:
6 files changed
+432
-16
No files matched your search
+173
-5
@@ -3156,6 +3156,9 @@ export class NostrClient {
|
||||
// “activeMap” holds only the newest version for each root
|
||||
this.activeMap = new Map();
|
||||
|
||||
// Track the newest deletion timestamp for each active key
|
||||
this.tombstones = new Map();
|
||||
|
||||
this.rootCreatedAtByRoot = new Map();
|
||||
|
||||
this.hasRestoredLocalData = false;
|
||||
@@ -3176,6 +3179,93 @@ export class NostrClient {
|
||||
storedPermissions instanceof Set ? storedPermissions : new Set();
|
||||
}
|
||||
|
||||
recordTombstone(activeKey, createdAt) {
|
||||
const key = typeof activeKey === "string" ? activeKey.trim() : "";
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Number(createdAt);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return;
|
||||
}
|
||||
timestamp = Math.max(0, Math.floor(timestamp));
|
||||
if (timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.tombstones.get(key);
|
||||
const previousTimestamp = Number.isFinite(previous)
|
||||
? Math.max(0, Math.floor(previous))
|
||||
: 0;
|
||||
if (previousTimestamp >= timestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tombstones.set(key, timestamp);
|
||||
|
||||
const activeVideo = this.activeMap.get(key);
|
||||
if (activeVideo) {
|
||||
const activeCreated = Number.isFinite(activeVideo?.created_at)
|
||||
? Math.floor(activeVideo.created_at)
|
||||
: 0;
|
||||
if (activeCreated && activeCreated <= timestamp) {
|
||||
activeVideo.deleted = true;
|
||||
this.activeMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const video of this.allEvents.values()) {
|
||||
if (!video || typeof video !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (getActiveKey(video) !== key) {
|
||||
continue;
|
||||
}
|
||||
const createdAtValue = Number.isFinite(video?.created_at)
|
||||
? Math.floor(video.created_at)
|
||||
: 0;
|
||||
if (createdAtValue && createdAtValue <= timestamp) {
|
||||
video.deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isOlderThanTombstone(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
if (!activeKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tombstone = this.tombstones.get(activeKey);
|
||||
if (!Number.isFinite(tombstone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedTombstone = Math.max(0, Math.floor(tombstone));
|
||||
const createdAtValue = Number.isFinite(video?.created_at)
|
||||
? Math.floor(video.created_at)
|
||||
: 0;
|
||||
|
||||
return createdAtValue > 0 && createdAtValue <= normalizedTombstone;
|
||||
}
|
||||
|
||||
applyTombstoneGuard(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isGuarded = this.isOlderThanTombstone(video);
|
||||
if (isGuarded) {
|
||||
video.deleted = true;
|
||||
}
|
||||
return isGuarded;
|
||||
}
|
||||
|
||||
markExtensionPermissions(methods = []) {
|
||||
if (!this.extensionPermissionCache) {
|
||||
this.extensionPermissionCache = new Set();
|
||||
@@ -3659,6 +3749,21 @@ export class NostrClient {
|
||||
this.rawEvents.clear();
|
||||
this.activeMap.clear();
|
||||
this.rootCreatedAtByRoot.clear();
|
||||
this.tombstones.clear();
|
||||
|
||||
if (Array.isArray(payload.tombstones)) {
|
||||
for (const entry of payload.tombstones) {
|
||||
if (!Array.isArray(entry) || entry.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const [key, value] = entry;
|
||||
const normalizedKey = typeof key === "string" ? key.trim() : "";
|
||||
const timestamp = Number.isFinite(value) ? Math.floor(value) : 0;
|
||||
if (normalizedKey && timestamp > 0) {
|
||||
this.tombstones.set(normalizedKey, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, video] of Object.entries(events)) {
|
||||
if (!id || !video || typeof video !== "object") {
|
||||
@@ -3666,12 +3771,19 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
this.applyRootCreatedAt(video);
|
||||
const activeKey = getActiveKey(video);
|
||||
|
||||
if (video.deleted) {
|
||||
this.recordTombstone(activeKey, video.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(video);
|
||||
}
|
||||
|
||||
this.allEvents.set(id, video);
|
||||
if (video.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
@@ -7139,7 +7251,7 @@ export class NostrClient {
|
||||
videoRootId: baseRoot,
|
||||
};
|
||||
|
||||
await this.revertVideo(
|
||||
const signedRevert = await this.revertVideo(
|
||||
{
|
||||
id: vid.id,
|
||||
pubkey: vid.pubkey,
|
||||
@@ -7161,9 +7273,15 @@ export class NostrClient {
|
||||
const activeKey = getActiveKey(cached);
|
||||
if (activeKey) {
|
||||
this.activeMap.delete(activeKey);
|
||||
const revertCreatedAt = Number.isFinite(signedRevert?.created_at)
|
||||
? Math.floor(signedRevert.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
this.recordTombstone(activeKey, revertCreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
this.saveLocalData();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7179,6 +7297,7 @@ export class NostrClient {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
events: {},
|
||||
tombstones: Array.from(this.tombstones.entries()),
|
||||
};
|
||||
|
||||
for (const [id, vid] of this.allEvents.entries()) {
|
||||
@@ -7239,18 +7358,34 @@ export class NostrClient {
|
||||
this.mergeNip71MetadataIntoVideo(video);
|
||||
this.applyRootCreatedAt(video);
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
const wasDeletedEvent = video.deleted === true;
|
||||
|
||||
if (wasDeletedEvent) {
|
||||
this.recordTombstone(activeKey, video.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(video);
|
||||
}
|
||||
|
||||
// Store in allEvents
|
||||
this.allEvents.set(evt.id, video);
|
||||
|
||||
// If it's a "deleted" note, remove from activeMap
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
this.activeMap.delete(activeKey);
|
||||
if (activeKey) {
|
||||
if (wasDeletedEvent) {
|
||||
this.activeMap.delete(activeKey);
|
||||
} else {
|
||||
const currentActive = this.activeMap.get(activeKey);
|
||||
if (currentActive?.id === video.id) {
|
||||
this.activeMap.delete(activeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, if it's newer than what we have, update activeMap
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
if (!prevActive || video.created_at > prevActive.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
@@ -7747,6 +7882,12 @@ export class NostrClient {
|
||||
} else {
|
||||
// Only add if good
|
||||
this.applyRootCreatedAt(vid);
|
||||
const activeKey = getActiveKey(vid);
|
||||
if (vid.deleted) {
|
||||
this.recordTombstone(activeKey, vid.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(vid);
|
||||
}
|
||||
localAll.set(evt.id, vid);
|
||||
}
|
||||
}
|
||||
@@ -7919,6 +8060,12 @@ export class NostrClient {
|
||||
|
||||
if (local) {
|
||||
this.applyRootCreatedAt(local);
|
||||
const localKey = getActiveKey(local);
|
||||
if (local.deleted) {
|
||||
this.recordTombstone(localKey, local.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(local);
|
||||
}
|
||||
if (!includeRaw) {
|
||||
return local;
|
||||
}
|
||||
@@ -7939,6 +8086,12 @@ export class NostrClient {
|
||||
|
||||
const video = convertEventToVideo(rawEvent);
|
||||
this.applyRootCreatedAt(video);
|
||||
const activeKey = getActiveKey(video);
|
||||
if (video.deleted) {
|
||||
this.recordTombstone(activeKey, video.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(video);
|
||||
}
|
||||
this.allEvents.set(eventId, video);
|
||||
|
||||
if (includeRaw) {
|
||||
@@ -8925,6 +9078,9 @@ export class NostrClient {
|
||||
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
|
||||
if (!seen.has(candidate.id)) {
|
||||
seen.add(candidate.id);
|
||||
if (!candidate.deleted) {
|
||||
this.applyTombstoneGuard(candidate);
|
||||
}
|
||||
matches.push(candidate);
|
||||
}
|
||||
}
|
||||
@@ -8971,6 +9127,12 @@ export class NostrClient {
|
||||
if (!parsed.invalid) {
|
||||
this.mergeNip71MetadataIntoVideo(parsed);
|
||||
this.applyRootCreatedAt(parsed);
|
||||
const activeKey = getActiveKey(parsed);
|
||||
if (parsed.deleted) {
|
||||
this.recordTombstone(activeKey, parsed.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(parsed);
|
||||
}
|
||||
this.allEvents.set(rootEvent.id, parsed);
|
||||
localMatches.push(parsed);
|
||||
}
|
||||
@@ -9025,6 +9187,12 @@ export class NostrClient {
|
||||
if (!parsed.invalid) {
|
||||
this.mergeNip71MetadataIntoVideo(parsed);
|
||||
this.applyRootCreatedAt(parsed);
|
||||
const activeKey = getActiveKey(parsed);
|
||||
if (parsed.deleted) {
|
||||
this.recordTombstone(activeKey, parsed.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(parsed);
|
||||
}
|
||||
this.allEvents.set(evt.id, parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -659,6 +659,14 @@ export class NostrService {
|
||||
|
||||
if (map.has(eventId)) {
|
||||
const existing = map.get(eventId);
|
||||
if (existing && !existing.deleted) {
|
||||
this.nostrClient.applyTombstoneGuard(existing);
|
||||
}
|
||||
if (!existing || existing.deleted) {
|
||||
map.delete(eventId);
|
||||
setStoredVideosMap(map);
|
||||
return null;
|
||||
}
|
||||
if (isBlockedNsfw(existing)) {
|
||||
map.delete(eventId);
|
||||
setStoredVideosMap(map);
|
||||
@@ -668,6 +676,11 @@ export class NostrService {
|
||||
}
|
||||
|
||||
const cached = this.nostrClient.allEvents.get(eventId);
|
||||
if (cached) {
|
||||
if (!cached.deleted) {
|
||||
this.nostrClient.applyTombstoneGuard(cached);
|
||||
}
|
||||
}
|
||||
if (cached && !cached.deleted) {
|
||||
if (isBlockedNsfw(cached)) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const VAR_PATTERN = /^var\(--.*\)$/;
|
||||
const CONTAINS_VAR_PATTERN = /var\(--.*\)/;
|
||||
const LINEAR_GRADIENT_PATTERN = /^linear-gradient\(/;
|
||||
const COLOR_MIX_PATTERN = /^color-mix\([\s\S]+\)$/;
|
||||
const NON_TOKEN_LENGTH_PATTERN = /(?!.*var\().*\b\d*\.?\d+(?:px|rem)\b/;
|
||||
|
||||
module.exports = {
|
||||
@@ -11,7 +12,7 @@ module.exports = {
|
||||
'color-no-hex': true,
|
||||
'declaration-property-value-allowed-list': {
|
||||
color: [VAR_PATTERN, 'inherit', 'currentColor'],
|
||||
'background-color': [VAR_PATTERN, 'transparent', 'inherit'],
|
||||
'background-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor', COLOR_MIX_PATTERN],
|
||||
background: [VAR_PATTERN, CONTAINS_VAR_PATTERN, LINEAR_GRADIENT_PATTERN, 'none', 'transparent'],
|
||||
'border-color': [VAR_PATTERN, CONTAINS_VAR_PATTERN, 'transparent', 'inherit', 'currentColor'],
|
||||
'outline-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { NostrClient } from "../js/nostr.js";
|
||||
|
||||
await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
localStorage.clear();
|
||||
const client = new NostrClient();
|
||||
client.hydrateVideoHistory = async () => {};
|
||||
|
||||
@@ -29,9 +30,13 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
client.allEvents.set(baseVideo.id, { ...baseVideo });
|
||||
|
||||
const revertCalls = [];
|
||||
const revertEvents = [];
|
||||
client.revertVideo = async (event) => {
|
||||
revertCalls.push(event);
|
||||
return { id: `revert-${revertCalls.length}` };
|
||||
const created_at = baseVideo.created_at + 500 + revertCalls.length;
|
||||
const revertEvent = { id: `revert-${revertCalls.length}`, created_at };
|
||||
revertEvents.push(revertEvent);
|
||||
return revertEvent;
|
||||
};
|
||||
|
||||
const result = await client.deleteAllVersions(videoRootId, pubkey, {
|
||||
@@ -50,6 +55,12 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
assert.equal(cached.deleted, true, "cached original video should be marked deleted");
|
||||
|
||||
const activeKey = `ROOT:${videoRootId}`;
|
||||
const tombstoneValue = client.tombstones.get(activeKey);
|
||||
assert.equal(
|
||||
tombstoneValue,
|
||||
revertEvents[0].created_at,
|
||||
"deleteAllVersions should seed tombstone with revert timestamp",
|
||||
);
|
||||
client.activeMap.set(activeKey, {
|
||||
...baseVideo,
|
||||
videoRootId,
|
||||
@@ -101,6 +112,161 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
|
||||
const storedDelete = client.allEvents.get(deleteEvent.id);
|
||||
assert.equal(storedDelete.deleted, true, "stored delete event should be marked deleted");
|
||||
assert.equal(
|
||||
client.tombstones.get(activeKey),
|
||||
deleteEvent.created_at,
|
||||
"subscription delete event should update tombstone",
|
||||
);
|
||||
})();
|
||||
|
||||
await (async function testTombstonePersistenceAcrossSaveRestore() {
|
||||
localStorage.clear();
|
||||
const client = new NostrClient();
|
||||
const videoRootId = "root-persist";
|
||||
const activeKey = `ROOT:${videoRootId}`;
|
||||
|
||||
const legacyVideo = {
|
||||
id: "persist-video",
|
||||
pubkey: "PUBKEY456",
|
||||
created_at: 1_700_000_100,
|
||||
version: 3,
|
||||
videoRootId,
|
||||
title: "Persisted",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "",
|
||||
mode: "live",
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", videoRootId],
|
||||
],
|
||||
};
|
||||
|
||||
client.allEvents.set(legacyVideo.id, { ...legacyVideo });
|
||||
client.recordTombstone(activeKey, legacyVideo.created_at + 50);
|
||||
client.saveLocalData();
|
||||
|
||||
const restored = new NostrClient();
|
||||
restored.restoreLocalData();
|
||||
|
||||
assert.equal(
|
||||
restored.tombstones.get(activeKey),
|
||||
legacyVideo.created_at + 50,
|
||||
"tombstone timestamp should persist across save/restore",
|
||||
);
|
||||
|
||||
const restoredVideo = restored.allEvents.get(legacyVideo.id);
|
||||
assert.equal(restoredVideo.deleted, true, "restored video should remain tombstoned");
|
||||
assert.equal(
|
||||
restored.activeMap.has(activeKey),
|
||||
false,
|
||||
"restored tombstoned video should not populate activeMap",
|
||||
);
|
||||
})();
|
||||
|
||||
await (async function testSubscribeVideosSkipsOlderThanTombstone() {
|
||||
localStorage.clear();
|
||||
const client = new NostrClient();
|
||||
client.populateNip71MetadataForVideos = async () => {};
|
||||
|
||||
const seenUpdates = [];
|
||||
const handlers = {};
|
||||
const fakeSub = {
|
||||
on(type, handler) {
|
||||
handlers[type] = handler;
|
||||
},
|
||||
unsub() {},
|
||||
};
|
||||
|
||||
client.pool = {
|
||||
sub() {
|
||||
return fakeSub;
|
||||
},
|
||||
};
|
||||
|
||||
client.subscribeVideos((videoUpdate) => {
|
||||
seenUpdates.push(videoUpdate);
|
||||
});
|
||||
|
||||
const pubkey = "PUBKEY789";
|
||||
const videoRootId = "root-sub";
|
||||
const tags = [
|
||||
["t", "video"],
|
||||
["d", videoRootId],
|
||||
];
|
||||
|
||||
const deleteEvent = {
|
||||
id: "delete-event-guard",
|
||||
pubkey,
|
||||
created_at: 1_700_100_000,
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
deleted: true,
|
||||
title: "",
|
||||
url: "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "",
|
||||
mode: "live",
|
||||
videoRootId,
|
||||
}),
|
||||
tags,
|
||||
};
|
||||
|
||||
handlers.event(deleteEvent);
|
||||
if (typeof handlers.eose === "function") {
|
||||
handlers.eose();
|
||||
}
|
||||
|
||||
const activeKey = `ROOT:${videoRootId}`;
|
||||
assert.equal(
|
||||
client.tombstones.get(activeKey),
|
||||
deleteEvent.created_at,
|
||||
"delete subscription event should seed tombstone",
|
||||
);
|
||||
assert.equal(
|
||||
client.activeMap.has(activeKey),
|
||||
false,
|
||||
"deleted subscription event should clear active map entry",
|
||||
);
|
||||
|
||||
const olderEvent = {
|
||||
id: "older-event",
|
||||
pubkey,
|
||||
created_at: deleteEvent.created_at - 60,
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
deleted: false,
|
||||
title: "Resurfaced",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "",
|
||||
mode: "live",
|
||||
videoRootId,
|
||||
}),
|
||||
tags,
|
||||
};
|
||||
|
||||
handlers.event(olderEvent);
|
||||
if (typeof handlers.eose === "function") {
|
||||
handlers.eose();
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
seenUpdates.length,
|
||||
0,
|
||||
"older events published after tombstone should not trigger callbacks",
|
||||
);
|
||||
|
||||
const storedOlder = client.allEvents.get(olderEvent.id);
|
||||
assert.equal(storedOlder.deleted, true, "older event should be marked deleted by tombstone guard");
|
||||
assert.equal(
|
||||
client.activeMap.has(activeKey),
|
||||
false,
|
||||
"older event should not repopulate the active map",
|
||||
);
|
||||
})();
|
||||
|
||||
console.log("nostr delete flow tests passed");
|
||||
@@ -741,10 +741,12 @@ test("applies token-based sizing and arrow positioning", async () => {
|
||||
const panel = documentRef.getElementById("panel-with-arrow");
|
||||
const arrow = panel.querySelector(".arrow");
|
||||
|
||||
assert.equal(panel.style.maxWidth, "320px");
|
||||
assert.equal(panel.style.maxHeight, "240px");
|
||||
const panelStyles = getComputedStyle(panel);
|
||||
assert.equal(panelStyles.maxWidth, "320px");
|
||||
assert.equal(panelStyles.maxHeight, "240px");
|
||||
assert.equal(arrow.dataset.popoverArrowSide, "bottom");
|
||||
assert.notEqual(arrow.style.top, "");
|
||||
const arrowStyles = getComputedStyle(arrow);
|
||||
assert.notEqual(arrowStyles.top, "");
|
||||
|
||||
popover.destroy();
|
||||
});
|
||||
@@ -57,11 +57,44 @@ test.describe("overlay layering tokens", () => {
|
||||
throw new Error("Missing sidebar layout nodes");
|
||||
}
|
||||
|
||||
const ensureProbeStyles = () => {
|
||||
if (document.getElementById("sidebar-width-probe-style")) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = "sidebar-width-probe-style";
|
||||
style.textContent = `
|
||||
.sidebar-width-probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar-width-probe--collapsed {
|
||||
width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));
|
||||
}
|
||||
|
||||
.sidebar-width-probe--expanded {
|
||||
width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap));
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
ensureProbeStyles();
|
||||
|
||||
const computeMargin = (widthVar) => {
|
||||
const variant =
|
||||
widthVar === "--sidebar-width-collapsed"
|
||||
? "sidebar-width-probe--collapsed"
|
||||
: widthVar === "--sidebar-width-expanded"
|
||||
? "sidebar-width-probe--expanded"
|
||||
: null;
|
||||
if (!variant) {
|
||||
throw new Error(`Unsupported sidebar width variable: ${widthVar}`);
|
||||
}
|
||||
|
||||
const probe = document.createElement("div");
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`;
|
||||
probe.classList.add("sidebar-width-probe", variant);
|
||||
document.body.appendChild(probe);
|
||||
const width = parseFloat(window.getComputedStyle(probe).width);
|
||||
probe.remove();
|
||||
@@ -101,11 +134,44 @@ test.describe("overlay layering tokens", () => {
|
||||
throw new Error("Missing sidebar layout nodes");
|
||||
}
|
||||
|
||||
const ensureProbeStyles = () => {
|
||||
if (document.getElementById("sidebar-width-probe-style")) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = "sidebar-width-probe-style";
|
||||
style.textContent = `
|
||||
.sidebar-width-probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar-width-probe--collapsed {
|
||||
width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));
|
||||
}
|
||||
|
||||
.sidebar-width-probe--expanded {
|
||||
width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap));
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
ensureProbeStyles();
|
||||
|
||||
const computeMargin = (widthVar) => {
|
||||
const variant =
|
||||
widthVar === "--sidebar-width-collapsed"
|
||||
? "sidebar-width-probe--collapsed"
|
||||
: widthVar === "--sidebar-width-expanded"
|
||||
? "sidebar-width-probe--expanded"
|
||||
: null;
|
||||
if (!variant) {
|
||||
throw new Error(`Unsupported sidebar width variable: ${widthVar}`);
|
||||
}
|
||||
|
||||
const probe = document.createElement("div");
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`;
|
||||
probe.classList.add("sidebar-width-probe", variant);
|
||||
document.body.appendChild(probe);
|
||||
const width = parseFloat(window.getComputedStyle(probe).width);
|
||||
probe.remove();
|
||||
|
||||
Reference in new issue
Block a user