diff --git a/CONTEXT.md b/CONTEXT.md index d591c20..521dcb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,7 +29,7 @@ The two views of a page: **as shipped** is the real product with every enhanceme _Avoid_: before/after as nouns for the views (they describe the clip, not the page), original, modified, live. **Enhanced badge**: -The small "Enhanced" tag in the top-right corner, present exactly while the **proposed** view differs from **as shipped** — so a viewer of a clip always knows which one they are seeing. +The small "Enhanced" tag at the top centre of the page, present exactly while the **proposed** view differs from **as shipped** — so a viewer of a clip always knows which one they are seeing. _Avoid_: watermark, label, indicator. ## Language — both diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16d0e03..9defa4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Ordinary git and GitHub practice — opening an issue, naming a branch, writing **Never commit or post:** - **Credentials of any kind** — API keys, tokens, cookies, session IDs, `.env` files, private keys. The `key` in `manifest.json` is a *public* key and is the one exception ([ADR-0002](docs/adr/0002-pin-the-extension-id.md)); its private half was never kept, and must never be generated and committed. -- **Saved copies of real pages.** A page saved from a logged-in site carries names, emails, org and project slugs, resource IDs and internal URLs in its markup. Keep real saves in the gitignored `test/fixtures/private/`, and write committed fixtures in `test/fixtures/` **by hand**: the smallest synthetic markup that reproduces the structure, with placeholder text (`acme`, `someone@example.com`, `00000000-0000-0000-0000-000000000000`). `test/fixtures-hygiene.test.js` fails on a real-looking email or UUID, but it is a backstop, not a licence — it cannot recognise a name or an org slug. +- **Saved copies of real pages.** A page saved from a logged-in site carries names, emails, org and project slugs, resource IDs and internal URLs in its markup. Keep real saves in the gitignored `test/fixtures/private/`, and write committed fixtures in `test/fixtures/` **by hand**: the smallest synthetic markup that reproduces the structure, with placeholder text (`acme`, `someone@example.com`, `00000000-0000-0000-0000-000000000000`, counting up in the last two digits when a fixture needs several). `test/fixtures-hygiene.test.js` fails on a real-looking email or UUID, but it is a backstop, not a licence — it cannot recognise a name or an org slug. - **Personal data** — names, emails, avatars or IDs of anyone but the author, and anything identifying a customer or account. - **Internal information** — internal Slack channels and threads, unreleased features or roadmap, internal URLs or hostnames, rollout plans, who said what, and screenshots of internal tools. - **Clips and screenshots.** Loom recordings and images of enhanced pages stay out of the repo; link to them from wherever they are shared internally, never from here. diff --git a/README.md b/README.md index 56e870a..d70ac65 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ On by default. For a readable shared screen, use Chrome's own zoom (`Cmd` `+`), 1. Start recording (Loom, or any screen recorder). 1. Press **Alt+Shift+E** to flip between the shipped page and the proposed one. Change the shortcut at `chrome://extensions/shortcuts`; the popup shows the current one. -While any enhancement is showing, an **Enhanced** badge sits in the top-right corner, so whoever watches the clip knows which version they are seeing. With every enhancement off — or the page flipped to *as shipped* — there is no badge and the page is exactly what shipped. +While any enhancement is showing, an **Enhanced** badge sits at the top centre of the page, so whoever watches the clip knows which version they are seeing. With every enhancement off — or the page flipped to *as shipped* — there is no badge and the page is exactly what shipped. New enhancements are added as files in [`enhancements/`](enhancements/index.js) — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and [ADR-0001](docs/adr/0001-enhancements-are-versioned-files.md). diff --git a/enhancements/forge-sandbox-status.js b/enhancements/forge-sandbox-status.js new file mode 100644 index 0000000..52a6c59 --- /dev/null +++ b/enhancements/forge-sandbox-status.js @@ -0,0 +1,63 @@ +/** + * Ended sandboxes in the Forge list read as a grey "Terminated" instead of a green "Completed". + * + * Targets data- attributes only: Forge's class names are build-hashed and change between + * releases. The grey reuses the pill's own colour variables with Forge's neutral values, so + * the proposed state looks native; fallbacks cover a page where those variables are missing. + * + * The overrides are !important because Forge raises its colour rules' specificity by + * repeating the class (`._green_x._green_x._green_x`), which outranks any plain selector here. + * They only exist while the enhancement is on, so the shipped page is untouched when off. + */ + +import { createRelabel } from "../src/lib/relabel.js"; + +const CLASS = "presenter-sandbox-ended"; + +// A ring with a short horizontal dash, like the European no-entry sign — calmer than a +// diagonal bar, and read as "ended" rather than "error". Drawn as a mask so it takes +// the pill's text colour, at the same 24-unit grid and size as the checkmark it replaces. +const ENDED_ICON = encodeURIComponent( + '' + + '' + + '' + + "", +); + +const relabel = createRelabel({ + selector: 'td[data-key$=":status"] [data-component="Pill"]', + from: "Completed", + to: "Terminated", + className: CLASS, +}); + +export const forgeSandboxStatus = { + id: "forge-sandbox-status", + title: "Ended sandboxes read as grey Terminated", + problem: + "An ended sandbox shows a green Completed, which reads as healthy or running; it cannot be started again, so Terminated in neutral grey says what it is.", + matches: ["https://forge.coreweave.com/sandboxes/*"], + css: ` +[data-component="Pill"].${CLASS} { + --tag-text-color: var(--secondary-text, #5f6368) !important; + --tag-background-color: light-dark(var(--moon-200, #e8eaed), var(--moon-800, #3c4043)) !important; +} +[data-component="Pill"].${CLASS} [data-icon] { + --icon-color: var(--secondary-text, #5f6368) !important; + position: relative; +} +[data-component="Pill"].${CLASS} [data-icon] svg { + visibility: hidden; +} +[data-component="Pill"].${CLASS} [data-icon]::before { + content: ""; + position: absolute; + inset: 0; + background-color: var(--icon-color); + -webkit-mask: url("data:image/svg+xml,${ENDED_ICON}") center / contain no-repeat; + mask: url("data:image/svg+xml,${ENDED_ICON}") center / contain no-repeat; +} +`, + apply: relabel.apply, + revert: relabel.revert, +}; diff --git a/enhancements/index.js b/enhancements/index.js index 53bd9ef..8195d49 100644 --- a/enhancements/index.js +++ b/enhancements/index.js @@ -15,4 +15,6 @@ * Add the new file's export to the list below. */ -export const ENHANCEMENTS = []; +import { forgeSandboxStatus } from "./forge-sandbox-status.js"; + +export const ENHANCEMENTS = [forgeSandboxStatus]; diff --git a/manifest.json b/manifest.json index a59c009..149ea85 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "presenter", - "version": "1.1.0", + "version": "1.2.0", "description": "Pointer and fading highlights for live demos, plus toggleable UX enhancements for before/after clips.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzSA8AU/9KdGceK/m1e9ke5Gi7B4HObymgejDydm3gAlJXrqcAys1TV5HsphZF/XG4ZNew/Ui2EzRGpJ3ryN9vP90EklOAgHIT4QLSL8zwTarwubELnoLHhqDFtxX/xe8fBb7zQsbXcZbcPHHlj0Fm2qkZgWHjrR1M+6/YzrxkFWOl0tgPibihEarn33hCCBvej/8AXDjF0JEV+farFxSc1f7jVNAgSe7O976GKlrXY6mYurJnXGv6G4EhPG1VGrsrVY/HcKHR3dotAcgp4qPIMHSNOfScxc75UWUKJarqCXKlX5NL9H3Vb84MmMwpOvo01SpDJZc/ZQRrxPOtMCRzQIDAQAB", "permissions": [ diff --git a/src/content/badge.js b/src/content/badge.js index 850ae2f..23de203 100644 --- a/src/content/badge.js +++ b/src/content/badge.js @@ -1,6 +1,6 @@ /** - * The Enhanced badge: a small top-right tag shown only while the page differs from what - * shipped. Top-right because Loom's camera bubble sits bottom-left by default. + * The Enhanced badge: a small top-centre tag shown only while the page differs from what + * shipped. At the top because Loom's camera bubble sits bottom-left by default. */ export const BADGE_ID = "presenter-enhanced-badge"; diff --git a/src/content/presenting.css b/src/content/presenting.css index 7f8bc9e..7b79fc9 100644 --- a/src/content/presenting.css +++ b/src/content/presenting.css @@ -40,11 +40,12 @@ } -/* The Enhanced badge — top-right, clear of Loom's default bottom-left camera bubble */ +/* The Enhanced badge — top centre, clear of Loom's default bottom-left camera bubble */ #presenter-enhanced-badge { position: fixed; top: 12px; - right: 12px; + left: 50%; + transform: translateX(-50%); z-index: 2147483647; padding: 4px 10px; border-radius: 999px; diff --git a/src/lib/relabel.js b/src/lib/relabel.js index 8bd42ad..b172e88 100644 --- a/src/lib/relabel.js +++ b/src/lib/relabel.js @@ -2,9 +2,11 @@ * A reversible text relabel — the building block for "say it differently" enhancements. * * Elements matched by `selector` whose trimmed text equals `from` get `to` as their text and - * `className` added. The original child nodes are kept (icons included) and put back by - * `revert`, so the restore is exact. An element the page re-rendered in the meantime is a new - * node that never carried our edit, so there is nothing to restore on it. + * `className` added. Only the element's own text nodes are rewritten — child elements such as + * an icon stay in place, and so do the text nodes themselves, which matters on a page + * rendered by a framework that keeps references to them. `revert` puts each node's original + * text back, so the restore is exact. An element the page re-rendered in the meantime is a + * new node that never carried our edit, so there is nothing to restore on it. * * `apply` skips elements already showing the new label, so it is idempotent. */ @@ -28,19 +30,29 @@ export function createRelabel({ selector, from, to, className }) { for (const element of doc.querySelectorAll(selector)) { // Already relabelled and still showing our text: nothing to do. If the page wrote over // our text in place (a framework re-render), fall through and relabel the new content. - if (element.hasAttribute(RELABELLED) && element.textContent === to) { + if (element.hasAttribute(RELABELLED) && element.textContent.trim() === to) { continue; } if (element.textContent.trim().toLowerCase() !== target) { continue; } - originals.set(element, [...element.childNodes]); + const textNodes = [...element.childNodes].filter( + (node) => node.nodeType === node.TEXT_NODE && node.data.trim() !== "", + ); + if (textNodes.length === 0) { + // The text sits deeper than this element; relabelling it would mean replacing child + // elements, which would not be reversible against a live framework. + continue; + } + originals.set(element, textNodes.map((node) => [node, node.data])); element.setAttribute(RELABELLED, ""); if (className && !element.classList.contains(className)) { element.classList.add(className); element.setAttribute(ADDED_CLASS, className); } - element.textContent = to; + textNodes.forEach((node, index) => { + node.data = index === 0 ? node.data.replace(node.data.trim(), to) : ""; + }); } }; @@ -49,7 +61,9 @@ export function createRelabel({ selector, from, to, className }) { if (!originals.has(element)) { continue; } - element.replaceChildren(...originals.get(element)); + for (const [node, data] of originals.get(element)) { + node.data = data; + } originals.delete(element); element.removeAttribute(RELABELLED); const added = element.getAttribute(ADDED_CLASS); diff --git a/test/fixtures-hygiene.test.js b/test/fixtures-hygiene.test.js index 1c14240..6c7af9c 100644 --- a/test/fixtures-hygiene.test.js +++ b/test/fixtures-hygiene.test.js @@ -9,7 +9,7 @@ const fixtures = join(import.meta.dirname, "fixtures"); const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/; const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i; // Synthetic placeholders that are allowed through. -const ALLOWED = [/@example\.(com|org)\b/, /\b0{8}-0{4}-0{4}-0{4}-0{12}\b/]; +const ALLOWED = [/@example\.(com|org)\b/, /\b0{8}-0{4}-0{4}-0{4}-0{10}\d{2}\b/]; function committedFixtures() { if (!existsSync(fixtures)) { @@ -36,5 +36,6 @@ describe("committed fixtures", () => { expect(scrub("contact jane@acme.test")).toMatch(EMAIL); expect(scrub("sandbox 12345678-abcd-4ef0-9abc-1234567890ab")).toMatch(UUID); expect(scrub("someone@example.com 00000000-0000-0000-0000-000000000000")).not.toMatch(EMAIL); + expect(scrub("00000000-0000-0000-0000-000000000000 00000000-0000-0000-0000-000000000042")).not.toMatch(UUID); }); }); diff --git a/test/fixtures/forge-sandbox-list.html b/test/fixtures/forge-sandbox-list.html new file mode 100644 index 0000000..de39031 --- /dev/null +++ b/test/fixtures/forge-sandbox-list.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + +
00000000-0000-0000-0000-000000000000Completed
00000000-0000-0000-0000-000000000001Running
Completed
diff --git a/test/forge-sandbox-status.test.js b/test/forge-sandbox-status.test.js new file mode 100644 index 0000000..a6e92e6 --- /dev/null +++ b/test/forge-sandbox-status.test.js @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createEnhancer } from "../src/content/enhancer.js"; +import { forgeSandboxStatus } from "../enhancements/forge-sandbox-status.js"; +import { urlMatches } from "../src/lib/match.js"; + +const fixture = readFileSync(join(import.meta.dirname, "fixtures", "forge-sandbox-list.html"), "utf8"); +const statusPills = () => [...document.querySelectorAll('td[data-key$=":status"] [data-component="Pill"]')]; + +describe("forge-sandbox-status", () => { + let original; + + beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = fixture; + original = document.documentElement.outerHTML; + }); + + it("targets the sandboxes pages only", () => { + expect(urlMatches("https://forge.coreweave.com/sandboxes/example-sandboxes", forgeSandboxStatus.matches)).toBe(true); + expect(urlMatches("https://forge.coreweave.com/notebooks", forgeSandboxStatus.matches)).toBe(false); + }); + + it("relabels a Completed status pill and keeps its icon", () => { + const [ended] = statusPills(); + const icon = ended.querySelector("[data-icon]"); + forgeSandboxStatus.apply(document); + expect(ended.textContent.trim()).toBe("Terminated"); + expect(ended.classList.contains("presenter-sandbox-ended")).toBe(true); + expect(ended.querySelector("[data-icon]")).toBe(icon); + }); + + it("leaves other statuses and Completed pills outside the status column alone", () => { + forgeSandboxStatus.apply(document); + const [, running] = statusPills(); + expect(running.textContent.trim()).toBe("Running"); + expect(document.querySelector('[data-key$=":displayName"] [data-component="Pill"]').textContent).toBe("Completed"); + }); + + it("restores the shipped page exactly when switched off", () => { + const enhancer = createEnhancer(document); + enhancer.sync([forgeSandboxStatus]); + expect(document.documentElement.outerHTML).not.toBe(original); + enhancer.sync([]); + expect(document.documentElement.outerHTML).toBe(original); + }); + + it("is idempotent across repeated syncs", () => { + const enhancer = createEnhancer(document); + enhancer.sync([forgeSandboxStatus]); + const once = document.documentElement.outerHTML; + enhancer.sync([forgeSandboxStatus]); + expect(document.documentElement.outerHTML).toBe(once); + }); +}); diff --git a/test/relabel.test.js b/test/relabel.test.js index 77f8c9d..c4f2386 100644 --- a/test/relabel.test.js +++ b/test/relabel.test.js @@ -15,19 +15,29 @@ describe("createRelabel", () => { it("relabels only matching elements with matching text", () => { relabel.apply(document); const [ended, running] = document.querySelectorAll(".status"); - expect(ended.textContent).toBe("Stopped"); + expect(ended.textContent.trim()).toBe("Stopped"); expect(ended.classList.contains("is-ended")).toBe(true); + expect(ended.querySelector(".icon")).not.toBeNull(); expect(running.textContent).toBe("running"); expect(document.querySelector(".other").textContent).toBe("completed"); }); - it("restores the page exactly, child nodes included", () => { + it("restores the page exactly, keeping the same nodes", () => { const original = document.body.innerHTML; - const icon = document.querySelector(".icon"); + const ended = document.querySelector(".status"); + const nodes = [...ended.childNodes]; relabel.apply(document); + expect([...ended.childNodes]).toEqual(nodes); relabel.revert(document); expect(document.body.innerHTML).toBe(original); - expect(document.querySelector(".icon")).toBe(icon); + expect([...ended.childNodes]).toEqual(nodes); + }); + + it("leaves an element alone when its text is nested deeper", () => { + document.body.innerHTML = `completed`; + const original = document.body.innerHTML; + relabel.apply(document); + expect(document.body.innerHTML).toBe(original); }); it("is idempotent", () => { @@ -48,8 +58,8 @@ describe("createRelabel", () => { it("relabels again when the page writes over our text in place", () => { relabel.apply(document); const ended = document.querySelector(".status"); - ended.textContent = "completed"; + ended.lastChild.data = "Completed"; relabel.apply(document); - expect(ended.textContent).toBe("Stopped"); + expect(ended.textContent.trim()).toBe("Stopped"); }); });