JS Injection Hack Usefuls (JavaScript)

To my knowledge, they haven't broke Pascal's color picker yet, but they warned us that they plan to.

Periscope's ClientResource feature is nearing completion, so any test use-cases you could provide would be great.

Here's an example of your PowerChart coloring code:

And here's a simple color picker:

Here's a fancier color picker. Stylesheets are compiled to CSS Modules, which allows for easy locally scoped styles (no more worrying about selector/naming collisions).

It's still this week in my time :face_with_peeking_eye: I haven't forgotten, I thought about this just yesterday actually haha

5000 Embedded Views
vs.
5000 Client-Resource React Components
(Notice they're both rendered using a FlexRepeater :wink: )

Wait what? :open_mouth:

What's a client resource again?

Client Resources are files that are compiled to JavaScript, that are loaded by the Perspective client on launch.

There are currently two types:

  1. TypeScript (supports JavaScript, TypeScript, JSX, and TSX)
  2. CSS Modules

All Client Resources are run by the client on page load/refresh. You can use this as a direct alternative to Markdown Injection.
In the earlier example (your PowerChart color modifier), a Client Resource is used to install observers when it loads.

Client Resources can provide React components. There is a new dedicated React Perspective component, or you can use them anywhere you would use an EmbeddedView.
This last example uses a Client Resource as a React component.

The massive difference in rendering is from several sources:

  1. Client Resources are fully cached by the client. They’re immediately on disk when it’s time to render.
  2. They require no synchronization with the gateway (i.e. heavy View setup stuff)

Technical Details:

  • Each Client Resource is an ES Module.
  • The gateway performs URL rewriting when serving the resource for cache busting.
  • URLs are rewritten using the hash of all Client Resources in the current project. As long as you don’t modify any resources, they can be cached by the client indefinitely. If you change any resources, they will all be redownloaded.

I am open to alternative names, I recognize that “Client Resources” is pretty vague and has multiple meanings already.

I like to use variations on the term "Supplement" for this kind of thing. Perhaps "Browser Supplemental Resources" is clear enough.

if you are looking for a test use case then you could use this javascript tutorial that I made in webdev and injected the mounted file.

you will have to change the tutorial steps to target selectors that exist on your page

//Tutorial Steps. each step contains
// text: the text thats in the tutorial text box
// targets: a list of selectors for the elements to highlight
// viewPath: the view to go to for the step. (must set message handler to work)
// (optional)onEnter: the scripts to run on step enter
// (optional)onExit: the scripts to run on step exit
// (optional)waitForClick: takes a element selector if set disables next button and sets the element as the next button
const tutorialSteps = [
    { text: "You can add and edit forms using these buttons. These buttons will take you to the next page. we will go there soon", targets: ["#backButton", "#submitButton"], viewPath: null },
    { text: "You can select rows in this table", targets: ["#FormTable"], viewPath: null },
    { text: "You can see your selected row here.", targets: ["#SelectedRow"], viewPath: null },
    { text: "You can remove your selection here.", targets: ["#RemoveSelection"], viewPath: null },
    { text: "You can refresh the table here.", targets: ["#RefreshTable"], viewPath: null },
    { text: "Open the search here to make finding forms easier. Try clicking it", targets: ["#OpenSearch"], viewPath: null, waitForClick: '#OpenSearch' },
    {
        text: "As you can see here is where you enter your filters.",
        targets: [".psc-Filters"],
        viewPath: null,
        onEnter:() => {
            const searchToggle = document.getElementById('SearchBar');
            if(searchToggle) {
                searchToggle.style.removeProperty('display');
            }
            const toggleContainer = document.querySelector('.ia_toggleSwitch');
            if(toggleContainer) {
                // 2. Find the specific elements inside that container
                const track = toggleContainer.querySelector('.ia_toggleSwitch__track');
                const thumb = toggleContainer.querySelector('.ia_toggleSwitch__thumb');


                // 3. Add the "selected" classes
                track.classList.add('ia_toggleSwitch__track--selected');
                thumb.classList.add('ia_toggleSwitch__thumb--selected');
            }
        },
        onExit:() => {
            const searchToggle = document.getElementById('SearchBar');
            if(searchToggle) {
                searchToggle.style.display = 'none';
            }
            const toggleContainer = document.querySelector('.ia_toggleSwitch');
            if(toggleContainer) {
                // 2. Find the specific elements inside that container
                const track = toggleContainer.querySelector('.ia_toggleSwitch__track--selected');
                const thumb = toggleContainer.querySelector('.ia_toggleSwitch__thumb--selected');

                // 3. Add the "selected" classes
                track.classList.replace('ia_toggleSwitch__track--selected', 'ia_toggleSwitch__track');
                thumb.classList.replace('ia_toggleSwitch__thumb--selected', 'ia_toggleSwitch__thumb');
            }
        }
    }
];

//Assign Variables
window.Tutorial = window.Tutorial || {};

const T = window.Tutorial

T.stepIndex = T.stepIndex ?? 0;
T.activeHighlights = T.activeHighlights ?? [];

T.popup = T.popup || document.createElement("div");
T.tutorialStartBtn = T.tutorialStartBtn || document.getElementById('TutorialButton');
T.progress = T.progress || document.createElement("div");
T.content = T.content || document.createElement("div");
T.nav = T.nav || document.createElement("div");
T.buttonStyle = T.buttonStyle || `
    padding: 8px 16px; border-radius: 6px; border: none; 
    cursor: pointer; font-weight: 600; font-size: 13px; 
    transition: all 0.2s; flex: 1;
`;
T.backBtn = T.backBtn ||document.createElement("button");
T.nextBtn = T.nextBtn || document.createElement("button");


T.popup.style.cssText = `
    position: fixed; bottom: 30px; right: 30px; 
    background: rgba(45, 45, 45, 0.95); backdrop-filter: blur(10px);
    color: #efefef; padding: 24px; border-radius: 12px; 
    z-index: 10000; width: 320px; 
    box-shadow: 0 10px 30px rgba(0,0,0,0.5); 
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    border: 1px solid rgba(255,255,255,0.1);
    transition: opacity 0.3s ease;
`;


T.progress.style.cssText = "font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;";
T.content.style.cssText = "font-size: 15px; line-height: 1.6; margin-bottom: 20px; min-height: 60px;";
T.nav.style.cssText = "display: flex; justify-content: space-between; align-items: center; gap: 12px;";

T.backBtn.style.cssText = T.buttonStyle + "background: #444; color: white;";
T.nextBtn.style.cssText = T.buttonStyle + "background: #007AFF; color: white;";

T.backBtn.textContent = "Back";
T.nextBtn.textContent = "Next";

T.popup.append(T.progress, T.content, T.nav);
T.nav.append(T.backBtn, T.nextBtn);

//Button Logic
T.backBtn.onclick = () => {
    if (T.stepIndex > 0) goToStep(T.stepIndex - 1);
};

T.nextBtn.onclick = () => {
    if (T.stepIndex < tutorialSteps.length - 1) {
        goToStep(T.stepIndex + 1);
    } else {
        clearHighlights();
        T.popup.style.opacity = '0';

        setTimeout(() => {
            const url = new URL(window.location.href);
            url.searchParams.set('reload', Date.now());
            window.location.href = url.toString();
        }, 300);
    }
};

//sets listner on element with DomID TutorialButton
if (T.tutorialStartBtn) {
    T.tutorialStartBtn.addEventListener('click', () => {
        StartTutorial();
    });
};


let cleanupListener = null;


//Highlights the seleted class or DomID
function highlight(selectors) {
    clearHighlights();

    selectors.forEach(selector => {
        const elements = document.querySelectorAll(selector); 

        elements.forEach(el => {
            el.style.outline = "4px solid yellow";
            el.style.boxShadow = "0 0 10px yellow";
            T.activeHighlights.push(el);
        });
    });
}

//Resets all the Highlights
function clearHighlights() {
    T.activeHighlights.forEach(el => {
        el.style.outline = "none";
        el.style.boxShadow = "none";
    });
    T.activeHighlights = [];
}

//Updates the Tutorial step
function update() {
    const s = tutorialSteps[T.stepIndex];

    T.content.textContent = s.text;
    T.progress.textContent = `Step ${T.stepIndex + 1} of ${tutorialSteps.length}`;

    T.backBtn.style.visibility = T.stepIndex === 0 ? "hidden" : "visible";
    T.nextBtn.textContent = T.stepIndex === tutorialSteps.length - 1 ? "Finish" : "Next";

    if (s.onEnter) {
        s.onEnter();
    }

    if (cleanupListener) {
        cleanupListener();
        cleanupListener = null;
    }

    if (s.waitForClick) {
        T.nextBtn.disabled = true;
        T.nextBtn.style.opacity = "0.5";
        T.nextBtn.style.cursor = "not-allowed";

        const el = document.querySelector(s.waitForClick);
        if (el) {
            const handler = () => {
                el.removeEventListener("click", handler);
                cleanupListener = null;
                goToStep(T.stepIndex + 1);
            };
            el.addEventListener("click", handler);
            cleanupListener = () => el.removeEventListener("click", handler);
        }
    } else {
        T.nextBtn.disabled = false;
        T.nextBtn.style.opacity = "1";
        T.nextBtn.style.cursor = "pointer";
    }
    setTimeout(() => {
        if (s.targets && s.targets.length > 0) {
            highlight(s.targets);
        } else {
            clearHighlights();
        }
    
        positionPopup(s.targets);
    }, 200);
}

//Positions the popup next to the highlighted elements
function positionPopup(targetSelectors) {
    const selectors = Array.isArray(targetSelectors) ? targetSelectors : [targetSelectors];
    const targets = selectors.flatMap(s => Array.from(document.querySelectorAll(s))).filter(Boolean);
    
    const padding = 20; 
    const offset = 15;  

    if (targets.length === 0) {
        popup.style.position = 'fixed';
        popup.style.bottom = `${padding}px`;
        popup.style.right = `${padding}px`;
        popup.style.top = 'auto';
        popup.style.left = 'auto';
        return;
    }

    let minLeft = Infinity, minTop = Infinity, maxRight = -Infinity, maxBottom = -Infinity;

    targets.forEach(target => {
        const r = target.getBoundingClientRect();
        if (r.width === 0 && r.height === 0) return; 
        if (r.left < minLeft) minLeft = r.left;
        if (r.top < minTop) minTop = r.top;
        if (r.right > maxRight) maxRight = r.right;
        if (r.bottom > maxBottom) maxBottom = r.bottom;
    });

    const rect = {
        left: minLeft,
        top: minTop,
        right: maxRight,
        bottom: maxBottom,
        width: maxRight - minLeft,
        height: maxBottom - minTop
    };

    const popupRect = T.popup.getBoundingClientRect();
    const scrollY = window.scrollY;

    popup.style.position = 'absolute';

    let left = rect.right + offset;
    let top = rect.top + scrollY;

    if (left + popupRect.width > window.innerWidth - padding) {
        left = rect.left - popupRect.width - offset; 
    }
    if (left < padding) {
        left = padding; 
    }

    if (top + popupRect.height > scrollY + window.innerHeight - padding) {
        top = scrollY + window.innerHeight - popupRect.height - padding;
    }
    if (top < scrollY + padding) {
        top = scrollY + padding;
    }

    popup.style.left = `${left}px`;
    popup.style.top = `${top}px`;
    popup.style.right = 'auto';
    popup.style.bottom = 'auto';

    targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}

//sends message to update viewpath
function sendViewMessage(path) {
    if (path && window.__client) {
        window.__client.connection.send("send-message", {
            'type': 'change-tutorial-view',
            'payload': {'newView': path },
            'scope': 'page'
        });
    }
}

//finds the previous viewpath for backwards traversal of tutorial
function findViewForStep(index) {
    for (let i = index; i >= 0; i--) {
        if (tutorialSteps[i].viewPath) {
            return tutorialSteps[i].viewPath;
        }
    }
    return null;
}

//Changes active tutorial step
function goToStep(newIndex) {
    const currentStep = tutorialSteps[T.stepIndex];
    const nextStep = tutorialSteps[newIndex];

    if (currentStep.onExit) {
        currentStep.onExit(nextStep);
    }

    T.stepIndex = newIndex;


    const targetView = findViewForStep(T.stepIndex);

    if (targetView) {
        sendViewMessage(targetView);
    }


    update();
}

//Starts tutorial
function StartTutorial() {
    T.stepIndex = 0;

    if (!document.body.contains(T.popup)) {
        document.body.append(T.popup);
    }

    update();
}

most of the other injections I've done are very closely tied to projects and wouldn't run standalone or I would send more.

This is a great example, thanks!

Disclaimer: I had a clanker do most of this.

Tutorial

const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React
const { createPortal } = ReactDOM

import perspective from 'perspective'
import styles from './Tutorial.css'

export interface TutorialStep {
  text: string
  targets?: string[]
  viewPath?: string | null
  waitForClick?: string
  onEnter?: () => void
  onExit?: (nextStep?: TutorialStep) => void
}

export interface TutorialProps {
  steps: TutorialStep[]
  open?: boolean
  onClose?: () => void
}

export function Tutorial({ steps, open = false, onClose }: TutorialProps) {
  const [stepIndex, setStepIndex] = useState(0)

  const [popupPosition, setPopupPosition] = useState({
    top: 30,
    left: window.innerWidth - 350,
  })

  const popupRef = useRef<HTMLDivElement>(null)
  const portalRef = useRef<HTMLDivElement | null>(null)

  const highlightedRef = useRef<HTMLElement[]>([])
  const cleanupListenerRef = useRef<(() => void) | null>(null)

  const step = steps[stepIndex]

  // Portal root
  useEffect(() => {
    const el = document.createElement('div')

    document.body.appendChild(el)

    portalRef.current = el

    return () => {
      document.body.removeChild(el)
    }
  }, [])

  const clearHighlights = useCallback(() => {
    highlightedRef.current.forEach((el) => {
      el.style.outline = ''
      el.style.boxShadow = ''
    })

    highlightedRef.current = []
  }, [])

  const highlight = useCallback(
    (selectors: string[] = []) => {
      clearHighlights()

      selectors.forEach((selector) => {
        document.querySelectorAll<HTMLElement>(selector).forEach((el) => {
          el.style.outline = '4px solid yellow'
          el.style.boxShadow = '0 0 10px yellow'

          highlightedRef.current.push(el)
        })
      })
    },
    [clearHighlights],
  )

  const sendViewMessage = useCallback((path?: string | null) => {
    perspective.sendMessage({
      type: 'change-tutorial-view',
      payload: { newView: path },
      scope: 'page',
    })
  }, [])

  const findViewForStep = useCallback(
    (index: number) => {
      for (let i = index; i >= 0; i--) {
        if (steps[i]?.viewPath) {
          return steps[i].viewPath
        }
      }

      return null
    },
    [steps],
  )

  const positionPopup = useCallback((selectors?: string[]) => {
    const popup = popupRef.current

    if (!popup) return

    const padding = 20
    const offset = 16

    const fallbackPosition = {
      top: window.innerHeight - popup.offsetHeight - padding,
      left: window.innerWidth - popup.offsetWidth - padding,
    }

    if (!selectors?.length) {
      setPopupPosition(fallbackPosition)
      return
    }

    const targets = selectors.flatMap((selector) => Array.from(document.querySelectorAll<HTMLElement>(selector)))

    if (!targets.length) {
      setPopupPosition(fallbackPosition)
      return
    }

    const rects = targets.map((el) => el.getBoundingClientRect()).filter((r) => r.width > 0 || r.height > 0)

    if (!rects.length) {
      setPopupPosition(fallbackPosition)
      return
    }

    const bounds = {
      left: Math.min(...rects.map((r) => r.left)),
      top: Math.min(...rects.map((r) => r.top)),
      right: Math.max(...rects.map((r) => r.right)),
      bottom: Math.max(...rects.map((r) => r.bottom)),
    }

    const popupWidth = popup.offsetWidth
    const popupHeight = popup.offsetHeight

    let left = bounds.right + offset
    let top = bounds.top

    // flip horizontally
    if (left + popupWidth > window.innerWidth - padding) {
      left = bounds.left - popupWidth - offset
    }

    // clamp horizontally
    left = Math.max(padding, Math.min(left, window.innerWidth - popupWidth - padding))

    // clamp vertically
    top = Math.max(padding, Math.min(top, window.innerHeight - popupHeight - padding))

    setPopupPosition({ top, left })

    targets[0]?.scrollIntoView({
      behavior: 'smooth',
      block: 'center',
    })
  }, [])

  const goToStep = useCallback(
    (newIndex: number) => {
      const currentStep = steps[stepIndex]
      const nextStep = steps[newIndex]

      currentStep?.onExit?.(nextStep)

      cleanupListenerRef.current?.()
      cleanupListenerRef.current = null

      setStepIndex(newIndex)

      const targetView = findViewForStep(newIndex)

      if (targetView) {
        sendViewMessage(targetView)
      }
    },
    [findViewForStep, sendViewMessage, stepIndex, steps],
  )

  useEffect(() => {
    if (!open || !step) return

    step.onEnter?.()

    if (step.waitForClick) {
      const el = document.querySelector(step.waitForClick)

      if (el) {
        const handler = () => {
          el.removeEventListener('click', handler)

          cleanupListenerRef.current = null

          if (stepIndex < steps.length - 1) {
            goToStep(stepIndex + 1)
          }
        }

        el.addEventListener('click', handler)

        cleanupListenerRef.current = () => el.removeEventListener('click', handler)
      }
    }

    return () => {
      cleanupListenerRef.current?.()
      cleanupListenerRef.current = null
    }
  }, [goToStep, open, step, stepIndex, steps.length])

  useLayoutEffect(() => {
    if (!open) return

    highlight(step.targets)

    requestAnimationFrame(() => {
      positionPopup(step.targets)
    })

    return () => {
      clearHighlights()
    }
  }, [clearHighlights, highlight, open, positionPopup, step.targets])

  const isLastStep = stepIndex === steps.length - 1

  const waitingForClick = Boolean(step.waitForClick)

  const finishTutorial = useCallback(() => {
    clearHighlights()
    onClose?.()
  }, [clearHighlights, onClose])

  if (!open || !portalRef.current) {
    return null
  }

  return createPortal(
    <div
      ref={popupRef}
      className={styles.popup}
      style={{
        top: popupPosition.top,
        left: popupPosition.left,
      }}
    >
      <div className={styles.progress}>
        Step {stepIndex + 1} of {steps.length}
      </div>

      <div className={styles.content}>{step.text}</div>

      <div className={styles.nav}>
        <button
          className={`${styles.button} ${styles.backButton}`}
          style={{
            visibility: stepIndex === 0 ? 'hidden' : 'visible',
          }}
          onClick={() => goToStep(stepIndex - 1)}
        >
          Back
        </button>

        <button
          className={`${styles.button} ${styles.nextButton}`}
          disabled={waitingForClick}
          onClick={() => {
            if (isLastStep) {
              finishTutorial()
            } else {
              goToStep(stepIndex + 1)
            }
          }}
        >
          {isLastStep ? 'Finish' : 'Next'}
        </button>
      </div>
    </div>,
    portalRef.current,
  )
}

export default function TutorialExample({ steps }) {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>Start Tutorial</button>

      <Tutorial open={open} steps={steps} onClose={() => setOpen(false)} />
    </>
  )
}

Tutorial.css

.popup {
  position: fixed;
  width: 320px;
  padding: 24px;

  background: rgba(45, 45, 45, 0.95);
  color: #efefef;

  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.1);

  backdrop-filter: blur(10px);

  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);

  z-index: 10000;

  font-family:
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    sans-serif;
}

.progress {
  margin-bottom: 8px;

  color: #888;

  font-size: 11px;
  letter-spacing: 1px;
  text-transform: uppercase;
}

.content {
  min-height: 60px;
  margin-bottom: 20px;

  font-size: 15px;
  line-height: 1.6;
}

.nav {
  display: flex;
  gap: 12px;
}

.button {
  flex: 1;

  padding: 8px 16px;

  border: none;
  border-radius: 6px;

  cursor: pointer;

  font-size: 13px;
  font-weight: 600;

  transition: all 0.2s;
}

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.backButton {
  background: #444;
  color: white;
}

.nextButton {
  background: #007aff;
  color: white;
}

And here's the magical part... I can provide you a project export :wink:
client-resource-testing_2026-05-19_1455.zip (7.5 KB)

Client Extensions was my next favorite option.

Blegh. Already taken for a user's own browser plug-ins.

I went down the rabbit hole with this - seeing what is possible. Documented here:

Yeah no, I'm not running any of that on my machine.

eval + atob = :fu:

@bmusson One thing I learnt recently is that switching between light and dark modes doesn't haven't to be so clunky and involve a transition to a styleless state, but it requires JavaScript to toggle between. I'm on mobile atm, can get the command when back at laptop, but I'm sure you're probably aware of it anyway. That's one thing I want to start using from now on, as you can animate the transition as well which looks significantly more professional. Im using your runJavascriptAsync function to call the toggle, I haven't got a current state of it coming back yet though

Totally fair pattern recognition with eval+atob being the malware fingerprint.

Every payload is also published in readable forms at scripts/*_payload_clean.js. The base64 was used to handle the quote escaping nightmare. And the repo clearly states to use a sandbox because it is all exploration - not production.

Fair, I can see why you’d do that.

FYI: I was this close to removing your post. Base64 code execution in an 1 week old AI generated repo? Dude, come on.

The replies themselves read like LLM generated. I would not engage further.

Of course I use LLMs, and especially to analyze what I want to type to ensure accuracy as well as grammar (sometimes, not all).

LLMs are a tool and I'd rather use an air compressor with air tools than hand tools. To each their own though.

Was hoping for more of a beneficial engagement with the cookbook - exploring the possibilities of the markdown component... :upside_down_face:

There does tend to be a general knee-jerk AI = bad/harmful/slop reaction. When I used it to create a game, I started announcing it as something that was AI-generated, because I found it intriguing just how powerful a tool it can be, but quickly realized I should probably shut up about it if I wanted anyone to take it seriously.

I used Claude to create an Exchange resource that uses Markdown JS injection, and it, too, uses the Base64 encoding technique.

If IA is going to kill the ability to use the Markdown component in this way, then I don't really see the point in continuing to explore the possibilities. I plan on asking for Claude's assistance in converting the two resources I've created into proper modules.

Thanks! Yes, I've been on both sides of the fence with the "AI Bad" feedback. Very early on I was one who would point that out but now have accepted the new landscape and tools available to improve my work and efficiency.

I'll take a look at your exchange resource - I have a few modules sitting in storage that I might spin up to wrap up a few of the more "fun" concepts from the cookbook. Already using my own color picker with the markdown. I have templates for modules for 8.1 and 8.3 versions if you'd like a starting point - I was considering making those repos public soon anyways.

IA might squash the escapeHTML in future revisions (we'll see) but no need to worry about something that hasn't been set in stone. lol other than keeping most of these unique ways out of production systems.