Strait of Hormuz Reopening Research — BULLISH OIL (8/10) — May 18, 2026

BULLISH OIL / BEARISH RISK ASSETS — Conviction: 8/10

1. What Happened

Iran blockaded the Strait of Hormuz on Feb 28, 2026 after US/Israel launched airstrikes and assassinated Khamenei. A ceasefire was agreed April 8 with Hormuz reopening as a condition, but Iran began charging $1M+ tolls per ship and restricting traffic. Trump launched “Project Freedom” naval escorts May 4 (destroyers, 15,000 troops, 100+ aircraft), exchanged fire with IRGC, then paused operations May 6 citing “great progress.” No breakthrough has followed.

2. Last 7 Days (May 11-18)

  • May 13: A single Chinese VLCC exited the Strait — first major transit in weeks (Bloomberg).
  • May 17: Daily transits ticked up to 10 ships from 5, but nearly all Iranian-linked vessels. Open commercial transits remain near zero.
  • May 18 (today): Brent spiked 2% to $112.10; WTI settled $108.66. The trigger: reports that the US views Iran’s latest proposal as “insufficient” (CNBC). Trump threatened “full, large-scale assault” if no deal is reached, while Gulf Arab leaders (Qatar, Saudi, UAE) claim serious back-channel talks via Pakistan are underway.
  • Talks remain deadlocked. Iran demands sanctions relief + end of US naval blockade as preconditions. US insists on unconditional Strait reopening first.

3. Key Financial Metrics

Metric Value
Brent crude $112.10 (+50% since Feb 28)
WTI crude $108.66
War-risk premium (VLCC) 1-5% of hull value ($10-14M per transit)
Pre-war premium 0.15-0.25%
Stranded oil inventory ~170M barrels on 166 tankers (Kpler)
Global supply at risk 20% of world crude + LNG
Normalization timeline 4-6 months minimum (DHL)
IEA warning Global inventories depleting at record pace

4. Sector Dynamics

  • China is the wildcard. Beijing has unique leverage with Tehran; Chinese-flagged vessels may get preferential passage.
  • UK deployed warships, drones, and fighters to support commercial shipping. Coalition forming but no results yet.
  • Insurance market is the real bottleneck. Lloyd’s war-risk premiums at 8-40x pre-war levels make transit economically unviable. Washington’s $40B insurance backstop got zero takers.
  • Alternative routes (Cape of Good Hope) add 10-15 days, straining tanker availability.

5. Risk Factors

  • Upside risk to oil: Talks collapse, Trump orders assault, $120-130 Brent.
  • Downside risk to oil: Surprise deal, war premium unwinds, Brent retraces to $85-90.
  • Insurance lag: Even with a deal, normalization takes 4-6 months.
  • Demand destruction: At $110+ Brent, EM demand already contracting. US SPR depleted.
  • Escalation risk: IRGC has mined the Strait. Any escort incident could spiral.

6. Conclusion: BULLISH OIL / BEARISH BROAD MARKET — 8/10

The Strait remains effectively closed. Negotiations deadlocked. The insurance market — the true gating factor — won’t normalize for months even after a deal. 170M barrels stranded + IEA record-depletion warning = sustained supply tightness.

Base case: no reopening before late Q3 2026. Oil stays bid. Energy longs = highest-conviction trade. Broad equities face headwinds from $100+ crude via margin compression and consumer spending drag.

Key reversal trigger: Verifiable, unconditional reopening agreement + Lloyd’s JWC downgrading Arabian Gulf from conflict-zone designation.

Sources: Bloomberg, CNBC, CNN, NPR, Axios, Lloyd’s List, Insurance Business, Kpler, IEA, UK House of Commons Library

ZS (Zscaler) Research Report — BULLISH (6/10) — May 18, 2026

# ZS (Zscaler) Research Report — May 18, 2026

**Verdict: BULLISH (6/10 conviction)**

## 1. What Happened

Google Trends spike (+33%) is driven by three converging catalysts: (a) a sector-wide cybersecurity rally ignited by Fortinet’s blowout earnings, lifting PANW +5.4%, CRWD +7%, and ZS ~17% over 7 days; (b) pre-earnings positioning ahead of ZS Q3 FY2026 results on **May 26** (after close); (c) a fresh wave of high-profile breaches (Itron, Medtronic/ShinyHunters, SharePoint zero-day CVE-2026-32201) reinforcing the cybersecurity spending narrative. No ZS-specific breach, deal, or product launch is responsible — this is sector momentum plus earnings anticipation.

## 2. Recent News & Sentiment (Last 7 Days)

– **+17% in 7 days**, +10% in a single session (May 14). Stock at ~$154, still down 54% from Nov 2025 high of $336.
– **Raised FY2026 ARR guidance** to $3.73–3.745B (24% growth), up from prior 23% forecast.
– **Google Cloud Partner of the Year** for Security (Application category), announced April 21 at Google Cloud Next.
– Analyst consensus: **Buy**, average PT $223–261 (44–69% upside from current).
– Fortinet’s earnings sparked the sector rotation; traders now front-running ZS’s May 26 print.

## 3. Key Financial Metrics

| Metric | Value |
|—|—|
| Price | ~$154.40 |
| Market Cap | $22.3B |
| 52-Week High / Low | $336 / ~$125 |
| FY2025 Revenue | $2.67B (+23% YoY) |
| ARR Guidance (FY2026) | $3.73–3.745B (+24%) |
| Net Loss (FY2025) | -$41.5M (improving, -28% vs FY2024) |
| Forward P/S | ~6x (compressed from 15x+ at peak) |

## 4. Competitive Landscape

– **Fortinet**: Cheapest quality name (30x forward, 80% gross margins, 28.6% net margins). Set the tone for the sector rally.
– **CrowdStrike**: $5.25B ARR, 24% growth, GAAP profitable, guiding FY27 revenue to $5.9B. Premium valuation justified.
– **Palo Alto**: Soaring alongside CRWD; platformization strategy gaining traction.
– **ZS positioning**: Most valuation-compressed of the group (down 54% from peak despite 24% ARR growth). If Q3 beats, mean-reversion trade has significant upside. Zero trust / SASE is a structural tailwind.

## 5. Risk Factors

1. **Earnings binary event in 8 days (May 26)**. Any miss or weak guidance will reverse the rally violently given the run-up.
2. **Still GAAP unprofitable**. Losses narrowing but not yet crossed to positive.
3. **Revenue growth decelerating** (23% in FY2025 vs 34% in FY2024). Market watching for re-acceleration signals.
4. **Sector crowding risk**. CIBR saw $30M in 5-day inflows; if the trade unwinds post-earnings, selling pressure will be amplified.
5. **Macro headwinds**. Stock down 30% YTD; broader IT budget pressure persists despite cyber resilience.

## 6. Cybersecurity ETF Flows

– **CIBR**: +$30.3M 5-day inflows, +$7.8M 1-month. Positive but not euphoric.
– **HACK**: +$4.2M 5-day, but -$18.8M 1-month, -$134M 6-month outflows. HACK losing assets to CIBR structurally.
– **Read**: Sector rotation is real but measured — not a flood. Supports a sustained move rather than a blow-off top.

## 7. Conclusion

**BULLISH (6/10 conviction).** The setup is attractive: massive valuation compression (6x forward P/S vs 15x+ historical), accelerating ARR (24%), sector tailwinds from Fortinet’s beat, and fresh breach catalysts. The Google Trends spike reflects legitimate pre-earnings positioning, not noise.

The cap on conviction: earnings in 8 days is a binary event, and the stock has already run 17% into it. A miss would be painful. For a swing trade, the risk/reward favors entry with a tight stop below $140. For a longer-term position, waiting for a post-earnings dip (if guidance disappoints) offers better entry. Analyst targets of $223–261 imply 44–69% upside if the growth story holds.

**Key date: May 26 after close — Q3 FY2026 earnings.**

Sources:
– [Zscaler Q3 FY2026 Earnings Date](https://www.stocktitan.net/news/ZS/zscaler-to-host-third-quarter-fiscal-year-2026-earnings-conference-eifkedqgo3gr.html)
– [ZS Stock Rally Analysis — Simply Wall St](https://simplywall.st/stocks/us/software/nasdaq-zs/zscaler/news/assessing-zscaler-zs-valuation-after-sector-momentum-and-upc)
– [Zscaler Google Cloud Partner Award](https://ir.zscaler.com/news-releases/news-release-details/zscaler-wins-2026-google-cloud-partner-year-award-security)
– [Cybersecurity Sector Rally — PANW/CRWD](https://www.theglobeandmail.com/investing/markets/stocks/PANW/pressreleases/1782238/palo-alto-networks-and-crowdstrike-shares-are-soaring-what-you-need-to-know/)
– [May 2026 Breaches — eSecurity Planet](https://www.esecurityplanet.com/weekly-roundup/supply-chain-attacks-ai-security-and-major-breaches-define-this-week-in-cybersecurity-in-may-2026/)
– [CIBR ETF Flows](https://etfdb.com/etf/CIBR/)
– [HACK ETF Flows](https://etfdb.com/etf/HACK/)
– [Cybersecurity ETFs — Motley Fool](https://www.fool.com/investing/2026/05/17/cybersecurity-is-no-longer-optional-for-any-busine/)

Iran Sanctions Relief: Posturing, Not Policy — Oil Stays Bid (May 18, 2026)

1. What Happened

On May 18, Iranian state media reported the US proposed a temporary oil sanctions waiver as part of ceasefire talks. Hours later, Axios reported a senior US official called Iran’s latest proposal “insufficient” — it contained rhetoric about not pursuing nuclear weapons but no concrete commitments on suspending enrichment or surrendering its HEU stockpile. The US demanded a 20-year enrichment moratorium; Iran offered 5. Trump meets his national security team Tuesday to discuss military options.

Verdict: This is Iranian media posturing to pressure the US negotiating position, not a credible near-term framework for sanctions relief.

2. Recent News & Sentiment (Last 7 Days)

  • May 18: Brent $111.04 (+1%), WTI $106.97 (+1%) after US rejection of Iran proposal
  • May 12: WTI briefly topped $108 before easing to $105 on conflicting Iran signals; S&P 500 closed above 7,400 for first time
  • May 7: Oil spiked on renewed Strait of Hormuz transit concerns
  • Weekend: Drone attack on UAE nuclear facility added regional risk premium
  • IEA May report: Revised 2026 to a supply deficit of 1.78M bpd (previously expected surplus)

3. Key Financial Metrics

Metric Value
Brent crude $111.04/bbl
WTI crude $106.97/bbl
Iran production (Apr 2026) 2.85M bpd (down from 3.1M in Jan)
Global supply deficit (IEA) 1.78M bpd projected for 2026
Inventory drawdown (Mar-Apr) 250M barrels (4M bpd rate)
Global demand forecast 104M bpd (-420K bpd YoY)
S&P 500 Above 7,400 (all-time highs)

4. Historical Precedent: 2015-16 JCPOA

When sanctions were lifted in January 2016, Iran added ~1M bpd to global supply, oil dropped ~$10/barrel (13%), and OPEC members lost 3.9% in per capita welfare. But that took 18+ months of negotiation after the framework. Today’s talks don’t even have an agreed framework.

5. Risk Factors

  • Upside risk to oil: Talks collapse Tuesday → military escalation → Strait of Hormuz fully blocked → Brent $130+
  • Downside risk to oil: Surprise breakthrough deal → 1-2M bpd back on market → crude drops $10-15
  • Recession risk: Analysts warn markets “sleepwalking into recession” from sustained $100+ oil
  • Energy sector whipsaw: Energy stocks fell 4.2% on a single day of peace talk optimism, then rallied on breakdown

6. Conclusion: BULLISH on Oil/Energy (7/10 Conviction)

No deal is imminent. The enrichment moratorium gap (5 vs. 20 years) is unbridgeable in the near term. The US rejected Iran’s latest offer and is now discussing military options. The IEA projects a 1.78M bpd supply deficit with inventories draining at 4M bpd. Oil stays bid above $100 until either a credible deal framework emerges or demand destruction accelerates.

Trade implication: Energy longs remain supported. Any dip on “deal hope” headlines is likely a buying opportunity until the enrichment gap narrows materially. Watch Tuesday’s NSC meeting for escalation signals.

Working Spectacle Download Link (macOS Window Manager)

If you’re like me and you’re reluctant to move on from Spectacle despite it being deprecated and you’re worried about downloading from various scammy looking sites, you can still get spectacle directly from the github repo safely

https://github.com/eczarny/spectacle/releases/download/1.2/Spectacle%2B1.2.zip

HIMS Research Summary — BEARISH (Medium Conviction)

Date: May 14, 2026 | Trigger: Q1 2026 earnings miss, GLP-1 transition margin compression


1. What Happened

Hims & Hers (HIMS) reported Q1 2026 on May 11 with a significant miss on both lines:

Metric Actual Estimate YoY
Revenue $608.1M $616.9M (miss) +3.8%
EPS (GAAP) -$0.40 +$0.04 (miss) vs +$0.20
Gross Margin 65% vs 73% (800bps compression)
Adj. EBITDA $44.3M vs $91.1M (-51%)
Subscribers 2.584M +9% YoY
Rev/Subscriber ~$80/mo vs $85/mo

Stock dropped 11.5% on May 12 to ~$24.14. The loss includes $33.5M in restructuring charges (GLP-1 transition), $17.6M fair value losses on liabilities, and $9.7M on equity securities.

2. Recent News & Sentiment (Last 7 Days)

  • Novo Nordisk Partnership: HIMS now offers branded Ozempic (0.5/1/2mg) and Wegovy (injections + oral tablets) on-platform, ending the compounded semaglutide legal dispute. Compounded GLP-1s will no longer be advertised.
  • Analyst downgrades: JPMorgan cut PT to $33 (from $35, maintains OW). BofA cut to $30 (from $32, Neutral). Jefferies lowered PT on guidance cut. Consensus is recalibrating.
  • Bullish outliers: Needham raised PT to $35 (Novo partnership upside). Canaccord raised to $32 (Buy).
  • Guidance raised: FY2026 revenue $2.8B-$3.0B (implies massive H2 acceleration from $608M/Q run rate), Adj. EBITDA $275M-$350M.
  • Sentiment: Heavily negative short-term. CNBC headline: “plummets 13%.” Social media bearish.

3. Key Financial Metrics

Metric Value
Market Cap ~$5.5B
Price $24.14 (May 14)
P/S (trailing) ~2.3x
52-Week Range ~$15-$72
Cash ~$200M (est.)
Shares Outstanding ~228M
FY2026 Rev Guide $2.8B-$3.0B

The FY guide implies $2.2B-$2.4B in remaining Q2-Q4 revenue ($730M-$800M/Q), a 20-32% acceleration from Q1’s $608M. This is aggressive and requires the Novo partnership to ramp fast.

4. Competitive Landscape / Sector Dynamics

  • GLP-1 telehealth: HIMS is pivoting from high-margin compounded semaglutide to lower-margin branded Novo products. Ro Health, Found, and Calibrate compete. HIMS has scale advantage (2.6M subs) but the margin profile changes fundamentally.
  • Novo dependency: HIMS is now a distribution channel for Novo Nordisk’s GLP-1s, not a differentiated compounding play. This reduces regulatory risk but compresses margins and makes HIMS dependent on Novo’s pricing and supply decisions.
  • FDA compounding enforcement: The shift to branded GLP-1s eliminates the overhang from potential FDA crackdowns on compounders. This was the right strategic move, but the transition cost is real.

5. Risk Factors

  • Margin trajectory: 800bps gross margin compression in one quarter is structural, not transitional. Branded pharma distribution margins are inherently lower than compounded products. Recovery to 73% is unlikely.
  • Guidance credibility: FY revenue guide requires Q2-Q4 to average $730M+/Q (20%+ sequential growth). This depends on Novo partnership ramping faster than compounded revenue declines.
  • Subscriber economics deteriorating: Rev/subscriber fell from $85 to $80. Branded GLP-1s may not reverse this if patients shift to lower-priced tiers or insurance-covered options.
  • One-time charge skepticism: $33.5M “restructuring” and $27.3M in fair value losses are labeled non-recurring, but the GLP-1 transition is multi-quarter. More charges are possible.
  • Options IV: Post-earnings IV is elevated, making puts expensive. Need to wait for IV crush before initiating.

6. Conclusion

Rating: BEARISH | Conviction: Medium (6/10)

The Q1 miss is not just one-time charges. The 800bps gross margin compression from 73% to 65% reflects a fundamental shift from high-margin compounding to lower-margin branded pharma distribution. Revenue per subscriber is declining. The FY guide requiring 20%+ sequential growth from Q2 onward is aggressive and unproven.

The Novo partnership is strategically correct but economically dilutive. HIMS traded away its margin moat (compounding) to eliminate regulatory risk. The market is right to reprice.
Put play assessment: Options are available (May 15/22/29 expirations + monthlies). However, post-earnings IV is elevated, making puts expensive now. A put spread (buy $24, sell $20, June expiry) could work if IV compresses over the next 1-2 weeks. Wait for IV to settle before entering. If the stock bounces to $26-27 on a relief rally, that’s the entry window for a bearish position.
Key watch: Q2 subscriber adds and rev/subscriber. If both decline sequentially, the bull thesis is broken. If HIMS can show 100K+ monthly weight loss subscriber adds (per management’s target), the narrative shifts.

Chime on Replit Completion

// ==UserScript==
// @name         Replit Chat Completion Ping (Stop/Working FSM + Airy Harp Chime)
// @namespace    nicholas.tools
// @version      1.4.0
// @description  Chimes when Replit chat finishes. Detects streaming via Stop or "Working..". Uses Sound #4 (Airy harp up-gliss). Rich console logs + control panel (window.ReplitPing).
// @match        https://replit.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /* =========================
   * Config
   * ========================= */
  let DEBUG = true;          // high-level logs (state changes, detections)
  let TRACE_SCAN = false;    // very chatty: log detector scans (toggle at runtime)
  const STABILITY_MS_DEFAULT = 200; // require this much time of "no Stop & no Working" before DONE
  let STABILITY_MS = STABILITY_MS_DEFAULT;
  const POLL_MS = 250;

  // Matchers (case-insensitive)
  const STOP_TEXTS = ["stop"];        // exact match (normalized)
  const WORKING_TOKENS = ["working"]; // starts-with; allows Working., Working.., Working…

  /* =========================
   * Pretty Console Logging
   * ========================= */
  const tag = (lvl) => [
    "%cREPLIT-PING%c " + lvl + "%c",
    "background:#121212;color:#00e5ff;padding:1px 4px;border-radius:3px",
    "color:#999",
    "color:inherit"
  ];
  const cI = (...a) => DEBUG && console.log(...tag("ℹ️"), ...a);
  const cS = (...a) => DEBUG && console.log(...tag("✅"), ...a);
  const cW = (...a) => DEBUG && console.log(...tag("⚠️"), ...a);
  const cE = (...a) => DEBUG && console.log(...tag("⛔"), ...a);
  const cT = (...a) => (DEBUG && TRACE_SCAN) && console.log(...tag("🔎"), ...a);

  const nowStr = () => new Date().toLocaleTimeString();

  /* =========================
   * DOM / Text Helpers
   * ========================= */
  const isEl = (n) => n && n.nodeType === 1;
  const isVisible = (el) => {
    if (!isEl(el)) return false;
    const rect = el.getBoundingClientRect?.();
    if (!rect || rect.width === 0 || rect.height === 0) return false;
    const st = getComputedStyle(el);
    if (st.display === "none" || st.visibility === "hidden" || parseFloat(st.opacity || "1") < 0.05) return false;
    return true;
  };

  const norm = (s) => (s || "")
    .replace(/\u2026/g, "...") // ellipsis → three dots
    .replace(/[.\s]+$/g, "")   // trim trailing dots/spaces
    .trim()
    .toLowerCase();

  function findVisibleEquals(tokens) {
    const hits = [];
    const nodes = document.querySelectorAll("span,button,[role='button'],div");
    let scanned = 0;
    for (const el of nodes) {
      scanned++;
      if (!isVisible(el)) continue;
      const txt = norm(el.textContent || "");
      if (tokens.some(tok => txt === tok)) {
        hits.push(el.closest("button,[role='button']") || el);
      }
    }
    cT(`findVisibleEquals scanned=${scanned} hits=${hits.length} tokens=${tokens.join(",")}`);
    return Array.from(new Set(hits));
  }

  function findVisibleStartsWith(tokens) {
    const hits = [];
    const nodes = document.querySelectorAll("span,div,button,[role='button']");
    let scanned = 0;
    for (const el of nodes) {
      scanned++;
      if (!isVisible(el)) continue;
      const txt = norm(el.textContent || "");
      if (tokens.some(tok => txt.startsWith(tok))) {
        hits.push(el);
      }
    }
    cT(`findVisibleStartsWith scanned=${scanned} hits=${hits.length} tokens=${tokens.join(",")}`);
    return Array.from(new Set(hits));
  }

  const isStopVisible    = () => findVisibleEquals(STOP_TEXTS).length > 0;
  const isWorkingVisible = () => findVisibleStartsWith(WORKING_TOKENS).length > 0;
  const isStreamingNow   = () => isStopVisible() || isWorkingVisible();

  /* =========================
   * SOUND #4 — Airy Harp Up-Gliss (HTMLAudio WAV + WebAudio fallback)
   *  - Four soft “pluck” notes rising: C5 → E5 → G5 → C6
   *  - Triangle oscillators with fast attack & gentle decay
   *  - Subtle global low-pass to keep it airy
   * ========================= */
  function makeReplitChimeWavDataURL() {
    const sr = 44100;
    const N  = Math.floor(sr * 1.05); // ~1.05s buffer
    const data = new Float32Array(N);

    // Pluck events
    const freqs = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
    const starts = [0.00, 0.06, 0.12, 0.18];        // seconds
    const A = 0.008;  // Attack seconds
    const D = 0.22;   // Total pluck duration (~decay to near zero)
    const amp = 0.62; // per-note peak

    const tri = (x) => (2 / Math.PI) * Math.asin(Math.sin(x));

    for (let n = 0; n < freqs.length; n++) {
      const f = freqs[n];
      const startIdx = Math.floor(starts[n] * sr);
      const len = Math.floor(D * sr);
      for (let i = 0; i < len && (startIdx + i) < N; i++) {
        const t = i / sr;
        // Envelope: quick attack → gentle exponential decay
        let env;
        if (t < A) {
          env = t / A;
        } else {
          const tau = 0.10; // decay constant
          env = Math.exp(-(t - A) / tau);
        }
        // Triangle pluck
        const s = tri(2 * Math.PI * f * t);
        data[startIdx + i] += amp * env * s;
      }
    }

    // Simple global low-pass (1st-order IIR) for softness (~4.5 kHz)
    const fc = 4500;
    const alpha = (2 * Math.PI * fc) / (2 * Math.PI * fc + sr);
    let y = 0;
    for (let i = 0; i < N; i++) {
      const x = data[i];
      y = y + alpha * (x - y);
      data[i] = y;
    }

    // Gentle soft clip
    for (let i = 0; i < N; i++) {
      const v = Math.max(-1, Math.min(1, data[i]));
      data[i] = Math.tanh(1.05 * v);
    }

    // Pack to 16-bit PCM WAV (mono)
    const bytes = 44 + N * 2;
    const dv = new DataView(new ArrayBuffer(bytes));
    let off = 0;
    const wStr = (s) => { for (let i = 0; i < s.length; i++) dv.setUint8(off++, s.charCodeAt(i)); };
    const w32 = (u) => { dv.setUint32(off, u, true); off += 4; };
    const w16 = (u) => { dv.setUint16(off, u, true); off += 2; };

    wStr("RIFF"); w32(36 + N * 2); wStr("WAVE");
    wStr("fmt "); w32(16); w16(1); w16(1); w32(sr); w32(sr * 2); w16(2); w16(16);
    wStr("data"); w32(N * 2);
    for (let i = 0; i < N; i++) {
      const v = Math.max(-1, Math.min(1, data[i]));
      dv.setInt16(off, v < 0 ? v * 0x8000 : v * 0x7FFF, true);
      off += 2;
    }

    // Base64 encode
    const u8 = new Uint8Array(dv.buffer);
    let b64 = "";
    for (let i = 0; i < u8.length; i += 0x8000) {
      b64 += btoa(String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)));
    }
    return `data:audio/wav;base64,${b64}`;
  }

  const CHIME_URL = makeReplitChimeWavDataURL();
  const primeAudioEl = new Audio(CHIME_URL);
  primeAudioEl.preload = "auto";

  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  let ctx;
  const ensureCtx = () => (ctx ||= new AudioCtx());

  async function playChime(reason) {
    // Primary: HTMLAudio
    try {
      const a = primeAudioEl.cloneNode();
      a.volume = 1.0;
      await a.play();
      cS(`🔊 Chime (Airy Harp, HTMLAudio) reason=${reason} @ ${nowStr()}`);
      return;
    } catch (e1) {
      cW("HTMLAudio failed; trying WebAudio (Airy Harp)", e1);
    }

    // Fallback: WebAudio version of Sound #4 (Airy Harp)
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      const c = window.__rp_ctx || new AC();
      window.__rp_ctx = c;
      if (c.state !== "running") await c.resume();

      const t0 = c.currentTime + 0.02;

      // Output chain: low-pass for softness
      const out = c.createGain(); out.gain.setValueAtTime(0.85, t0);
      const lp  = c.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 4500; lp.Q.value = 0.6;
      out.connect(lp); lp.connect(c.destination);

      const pluck = (time, f) => {
        const o = c.createOscillator(); o.type = "triangle"; o.frequency.setValueAtTime(f, time);
        const g = c.createGain();
        // Envelope: fast attack, gentle decay
        g.gain.setValueAtTime(0.0001, time);
        g.gain.exponentialRampToValueAtTime(0.6,  time + 0.008);
        g.gain.exponentialRampToValueAtTime(0.001, time + 0.22);
        o.connect(g); g.connect(out);
        o.start(time); o.stop(time + 0.25);
      };

      const freqs = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
      let cur = t0;
      for (const f of freqs) { pluck(cur, f); cur += 0.06; }

      cS(`🔊 Chime (Airy Harp, WebAudio) reason=${reason} @ ${nowStr()}`);
    } catch (e2) {
      cE("WebAudio play failed", e2);
    }
  }

  // Unlock audio on first interaction/visibility
  const unlock = async () => {
    try { await primeAudioEl.play(); primeAudioEl.pause(); primeAudioEl.currentTime = 0; cI("Audio unlocked via HTMLAudio"); } catch {}
    try { if (AudioCtx) { const c = ensureCtx(); if (c.state !== "running") await c.resume(); cI("AudioContext resumed"); } } catch {}
    window.removeEventListener("pointerdown", unlock, true);
    window.removeEventListener("keydown", unlock, true);
  };
  window.addEventListener("pointerdown", unlock, true);
  window.addEventListener("keydown", unlock, true);
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") unlock(); });

  /* =========================
   * FSM (Stop/Working-driven)
   * ========================= */
  let sid = 0;
  let s = null;
  let pollId = 0;
  let lastStop = false;
  let lastWork = false;

  const STATE = { IDLE: "IDLE", STREAMING: "STREAMING", DONE: "DONE" };

  function startPoll() {
    if (pollId) return;
    pollId = window.setInterval(tick, POLL_MS);
  }
  function stopPoll() {
    if (pollId) { clearInterval(pollId); pollId = 0; }
  }

  function armStreaming(origin) {
    if (s && s.state !== STATE.DONE) return;
    s = { id: ++sid, state: STATE.STREAMING, lastStableStart: 0, sawStreaming: true };
    cI(`▶️ STREAMING s#${s.id} (origin=${origin}) @ ${nowStr()}`);
    startPoll();
  }

  function maybeComplete() {
    if (!s || s.state === STATE.DONE) return;
    const streaming = isStreamingNow();
    const now = performance.now();

    // Visibility transition logs
    const curStop = isStopVisible();
    const curWork = isWorkingVisible();
    if (curStop !== lastStop) {
      cI(`Stop visibility: ${curStop ? "ON" : "OFF"}`);
      lastStop = curStop;
    }
    if (curWork !== lastWork) {
      cI(`Working visibility: ${curWork ? "ON" : "OFF"}`);
      lastWork = curWork;
    }

    if (!streaming) {
      if (!s.lastStableStart) {
        s.lastStableStart = now;
        cI(`⏳ Stability window started (${STABILITY_MS}ms)`);
      }
      const elapsed = now - s.lastStableStart;
      if (elapsed >= STABILITY_MS) {
        s.state = STATE.DONE;
        cS(`DONE s#${s.id} (no Stop & no Working for ${Math.round(elapsed)}ms) @ ${nowStr()}`);
        playChime(`s#${s.id}`);
        stopPoll();
        s = null;
      }
    } else {
      if (s.lastStableStart) cW("Stability reset (streaming reappeared)");
      s.lastStableStart = 0;
    }
  }

  function tick() {
    const streaming = isStreamingNow();
    if (streaming && (!s || s.state === STATE.DONE)) {
      armStreaming("tick");
    }
    if (s && s.state === STATE.STREAMING) {
      maybeComplete();
    }
  }

  /* =========================
   * Observers & Event hooks
   * ========================= */
  const obs = new MutationObserver((mutations) => {
    let nudge = false;
    for (const m of mutations) {
      if (m.type === "childList") {
        for (const n of [...m.addedNodes, ...m.removedNodes]) {
          if (isEl(n)) {
            const txt = norm(n.textContent || "");
            if (STOP_TEXTS.some(s => txt.includes(s)) || WORKING_TOKENS.some(w => txt.startsWith(w))) { nudge = true; break; }
          }
        }
      } else if (m.type === "attributes") {
        const el = m.target;
        if (!isEl(el)) continue;
        const txt = norm(el.textContent || "");
        if (STOP_TEXTS.some(s => txt.includes(s)) || WORKING_TOKENS.some(w => txt.startsWith(w))) { nudge = true; }
      }
      if (nudge) break;
    }
    if (nudge) {
      cT("Mutation nudged tick()");
      tick();
    }
  });

  function start() {
    if (!document.body) {
      document.addEventListener("DOMContentLoaded", start, { once: true });
      return;
    }
    obs.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["class", "style", "aria-hidden"]
    });
    startPoll();
    cI("Armed (Stop/Working FSM). Will chime when both disappear (stable).");
    // Initial state snapshot
    lastStop = isStopVisible();
    lastWork = isWorkingVisible();
    cI(`Initial: Stop=${lastStop} Working=${lastWork}`);
    if (lastStop || lastWork) armStreaming("initial");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", start, { once: true });
  } else {
    start();
  }

  /* =========================
   * Runtime Controls (Console)
   * =========================
   * window.ReplitPing.setDebug(true|false)
   * window.ReplitPing.setTrace(true|false)
   * window.ReplitPing.setStability(ms)
   * window.ReplitPing.status()
   * window.ReplitPing.test()
   */
  window.ReplitPing = {
    setDebug(v){ DEBUG = !!v; cI(`DEBUG=${DEBUG}`); return DEBUG; },
    setTrace(v){ TRACE_SCAN = !!v; cI(`TRACE_SCAN=${TRACE_SCAN}`); return TRACE_SCAN; },
    setStability(ms){ STABILITY_MS = Math.max(0, Number(ms)||STABILITY_MS_DEFAULT); cI(`STABILITY_MS=${STABILITY_MS}`); return STABILITY_MS; },
    status(){
      const streaming = isStreamingNow();
      const state = s ? s.state : STATE.IDLE;
      const info = {
        state,
        sessionId: s?.id ?? null,
        stopVisible: isStopVisible(),
        workingVisible: isWorkingVisible(),
        streaming,
        stabilityMs: STABILITY_MS,
        pollActive: !!pollId,
        time: nowStr(),
      };
      cI("Status", info);
      return info;
    },
    async test(){ await playChime("manual-test"); return true; }
  };

})();

A tool for importing from Google Docs that doesn’t suck (Mammoth .docx converter)

I was tired of WordPress’s crappy editor so I decided to write a post in Google Docs instead.

However, when copying the content back in. All the images I carefully added did not carry back over. After trying several terrible options (Seraphinite Post .DOCX Source, WordPress.com for Google Docs, docswrite.com — this didn’t work when I tried but seems to function fine after going back and forth with their CEO) I finally tried out Mammoth and it just works which is great.

  1. Add a post
  2. Look for the Mammoth .docx converter box at the bottom
  3. Upload your file
  4. Wait for it to parse
  5. Click on Insert into editor
  6. And then wait for it to finish adding all the content into the editor (my docx took a few minutes since it had quite a few high resolution images)

Japan Recommendations

A quick note on the below content. All of the things I’ve included I personally enjoyed greatly and would enjoy to anyone who shares my tastes. Have fun in Japan!

Tokyo

Tokyo food

  • L’Effervescence


  • Oniku Karyu
  • Cokuun (Coffee Omakase)
  • I’m Donut ?
  • Pizza Bar


  • Cycle
  • IPPUKU&MATCHA

  • Blue Bottle Cafe (surprisingly good Matcha Latte)

  • Parklet Bakery


  • Iki Expresso


  • Le Petit Mec Hibiya (the best pastries I’ve had in Tokyo)

Tokyo things to do

  • Nezu Museum

  • Hamarikyu Gardens



  • Ueno Park (come at golden hour)



  • Tokyo National Museum
  • Teamlabs Borderless
  • Teamlabs Planets

Kyoto

Kyoto Food

  • Akagakiya

Kyoto things to do

  • Kyoto Golden Temple
  • Otagi Nenbutsuji Temple
  • Arashiyama Bamboo Grove (make sure you hike up into the park by the Bamboo Grove for the Valley View!)
  • Mt Inari (go early and hike to the top)
  • Kiyomizu-dera Temple (this was the most spectacular temple that I visited)

  • Ruriko-in Temple

Osaka

Osaka Food

Osaka Things to do

  • Osaka Castle (go at sunrise, its spectacular!)

Other places

Lake Kawaguchi

Onomichi

  • Shinomani-Kaido

Himeji

  • Himeji Castle



Wakayama

  • Tatago Rock

Nara

  • Nara Park


Nikko

  • Kinfuri Falls


  • Shinkyo Bridge


  • Nikko Tamozawa Imperial Villa Memorial Park




  • Nikkō Tōshogū


Tokyo

Tokyo food

  • L’Effervescence

  • Oniku Karyu
  • Cokuun (Coffee Omakase)
  • I’m Donut ?
  • Pizza Bar

  • Cycle
  • IPPUKU&MATCHA

  • Blue Bottle Cafe (surprisingly good Matcha Latte)

  • Parklet Bakery

  • Iki Expresso

  • Le Petit Mec Hibiya (the best pastries I’ve had in Tokyo)

Tokyo things to do

  • Nezu Museum

  • Hamarikyu Gardens

  • Ueno Park (come at golden hour)

  • Tokyo National Museum
  • Teamlabs Borderless
  • Teamlabs Planets

Kyoto

Kyoto Food

  • Akagakiya

Kyoto things to do

  • Kyoto Golden Temple
  • Otagi Nenbutsuji Temple
  • Arashiyama Bamboo Grove (make sure you hike up into the park by the Bamboo Grove for the Valley View!)
  • Mt Inari (go early and hike to the top)
  • Kiyomizu-dera Temple (this was the most spectacular temple that I visited)

  • Ruriko-in Temple

Osaka

Osaka Food

Osaka Things to do

  • Osaka Castle (go at sunrise, its spectacular!)

Other places

Lake Kawaguchi

Onomichi

  • Shinomani-Kaido

Himeji

  • Himeji Castle

Wakayama

  • Tatago Rock

Nara

  • Nara Park

Nikko

  • Kinfuri Falls

  • Shinkyo Bridge

  • Nikko Tamozawa Imperial Villa Memorial Park

  • Nikkō Tōshogū

Tokyo

Tokyo food

  • L’Effervescence

  • Oniku Karyu
  • Cokuun (Coffee Omakase)
  • I’m Donut ?
  • Pizza Bar

  • Cycle
  • IPPUKU&MATCHA

  • Blue Bottle Cafe (surprisingly good Matcha Latte)

  • Parklet Bakery

  • Iki Expresso

  • Le Petit Mec Hibiya (the best pastries I’ve had in Tokyo)

Tokyo things to do

  • Nezu Museum

  • Hamarikyu Gardens

  • Ueno Park (come at golden hour)

  • Tokyo National Museum
  • Teamlabs Borderless
  • Teamlabs Planets

Kyoto

Kyoto Food

  • Akagakiya

Kyoto things to do

  • Kyoto Golden Temple
  • Otagi Nenbutsuji Temple
  • Arashiyama Bamboo Grove (make sure you hike up into the park by the Bamboo Grove for the Valley View!)
  • Mt Inari (go early and hike to the top)
  • Kiyomizu-dera Temple (this was the most spectacular temple that I visited)

  • Ruriko-in Temple

Osaka

Osaka Food

Osaka Things to do

  • Osaka Castle (go at sunrise, its spectacular!)

Other places

Lake Kawaguchi

Onomichi

  • Shinomani-Kaido

Himeji

  • Himeji Castle

Wakayama

  • Tatago Rock

Nara

  • Nara Park

Nikko

  • Kinfuri Falls

  • Shinkyo Bridge

  • Nikko Tamozawa Imperial Villa Memorial Park

  • Nikkō Tōshogū

ChatGPT Chime on Chat Completion (Tampermonkey Script)

Tampermonkey script

// ==UserScript==
// @name         ChatGPT Completion Ping (Composer FSM, background-safe, no-timeout)
// @namespace    nicholas.tools
// @version      5.4.0
// @description  Chime on completion even when window/tab isn't focused. No timeout; FSM: saw Stop → Stop gone + editor empty. Poll + resilient audio.
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /* =========================
   * Logging
   * ========================= */
  const DEBUG = true;
  const log = (...a) => DEBUG && console.log("[COMP-PING]", ...a);
  const t = () => new Date().toLocaleTimeString();

  /* =========================
   * Selectors (composer only)
   * ========================= */
  const COMPOSER_EDITABLE    = '#prompt-textarea.ProseMirror[contenteditable="true"]';
  const COMPOSER_FALLBACK_TA = 'textarea[name="prompt-textarea"]';
  const SEND_BTN             = '#composer-submit-button[data-testid="send-button"]';
  const STOP_BTN             = '#composer-submit-button[data-testid="stop-button"]';

  /* =========================
   * Audio: HTMLAudio primary (WAV data URL), WebAudio fallback
   * ========================= */
  function makeChimeWavDataURL() {
    const sr = 44100, dur = 0.99;
    const notes = [
      { f: 987.77, d: 0.22 }, { f: 1318.51, d: 0.22 },
      { f: 1174.66, d: 0.20 }, { f: 1318.51, d: 0.30 },
    ];
    const gap = 0.055, amp = 0.28;
    const N = Math.floor(sr * dur);
    const data = new Float32Array(N).fill(0);
    let t0 = 0;
    for (const { f, d } of notes) {
      const nSamp = Math.floor(d * sr);
      const start = Math.floor(t0 * sr);
      for (let i = 0; i < nSamp && start + i < N; i++) {
        const env = i < 0.01*sr ? i/(0.01*sr) : (i > nSamp-0.03*sr ? Math.max(0, (nSamp - i)/(0.03*sr)) : 1);
        const s = Math.sin(2*Math.PI*f*(i/sr));
        const s2 = Math.sin(2*Math.PI*(f*1.005)*(i/sr)) * 0.6;
        data[start+i] += amp * env * (0.7*s + 0.3*s2);
      }
      t0 += d + gap;
    }
    const pcm = new DataView(new ArrayBuffer(44 + N*2));
    let off = 0;
    const wStr = (s) => { for (let i=0;i<s.length;i++) pcm.setUint8(off++, s.charCodeAt(i)); };
    const w32  = (u) => { pcm.setUint32(off, u, true); off+=4; };
    const w16  = (u) => { pcm.setUint16(off, u, true); off+=2; };
    wStr("RIFF"); w32(36 + N*2); wStr("WAVE");
    wStr("fmt "); w32(16); w16(1); w16(1); w32(sr); w32(sr*2); w16(2); w16(16);
    wStr("data"); w32(N*2);
    for (let i=0;i<N;i++) { const v = Math.max(-1, Math.min(1, data[i])); pcm.setInt16(off, v<0?v*0x8000:v*0x7FFF, true); off+=2; }
    const u8 = new Uint8Array(pcm.buffer);
    const b64 = btoa(String.fromCharCode(...u8));
    return `data:audio/wav;base64,${b64}`;
  }

  const CHIME_URL = makeChimeWavDataURL();
  const primeAudioEl = new Audio(CHIME_URL);
  primeAudioEl.preload = "auto";

  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  let ctx;
  const ensureCtx = () => (ctx ||= new AudioCtx());

  async function playChime(reason) {
    try {
      const a = primeAudioEl.cloneNode();
      a.volume = 1.0;
      await a.play();
      log(`🔊 DONE (HTMLAudio) ${reason} @ ${t()}`);
      return;
    } catch {}
    try {
      const c = ensureCtx();
      if (c.state !== "running") await c.resume();
      const t0 = c.currentTime + 0.02;
      const master = c.createGain(); master.gain.setValueAtTime(0.9, t0); master.connect(c.destination);
      const lp = c.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=4200; lp.Q.value=0.6; lp.connect(master);
      const delay = c.createDelay(0.5); delay.delayTime.value=0.18;
      const fb = c.createGain(); fb.gain.value=0.22; delay.connect(fb); fb.connect(delay); delay.connect(master);
      const bus = c.createGain(); bus.gain.value=0.85; bus.connect(lp); bus.connect(delay);

      const seq = [
        { f: 987.77, d: 0.22 }, { f: 1318.51, d: 0.22 },
        { f: 1174.66, d: 0.20 }, { f: 1318.51, d: 0.30 },
      ];
      let cur = t0, gap = 0.055;
      for (const {f,d} of seq) {
        const o1=c.createOscillator(), g1=c.createGain(); o1.type="triangle"; o1.frequency.value=f;
        g1.gain.setValueAtTime(0.0001,cur); g1.gain.exponentialRampToValueAtTime(0.6,cur+0.01); g1.gain.exponentialRampToValueAtTime(0.001,cur+d);
        o1.connect(g1); g1.connect(bus); o1.start(cur); o1.stop(cur+d+0.02);

        const o2=c.createOscillator(), g2=c.createGain(); o2.type="sine"; o2.frequency.setValueAtTime(f*1.005,cur);
        g2.gain.setValueAtTime(0.0001,cur); g2.gain.exponentialRampToValueAtTime(0.35,cur+0.012); g2.gain.exponentialRampToValueAtTime(0.001,cur+d);
        o2.connect(g2); g2.connect(bus); o2.start(cur); o2.stop(cur+d+0.02);

        cur += d + gap;
      }
      log(`🔊 DONE (WebAudio) ${reason} @ ${t()}`);
    } catch {}
  }

  // Prime on user interaction
  const unlock = async () => {
    try { await primeAudioEl.play(); primeAudioEl.pause(); primeAudioEl.currentTime = 0; } catch {}
    try { if (AudioCtx) { const c = ensureCtx(); if (c.state !== "running") await c.resume(); } } catch {}
    window.removeEventListener("pointerdown", unlock, true);
    window.removeEventListener("keydown", unlock, true);
  };
  window.addEventListener("pointerdown", unlock, true);
  window.addEventListener("keydown", unlock, true);
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") unlock(); });

  /* =========================
   * Composer helpers
   * ========================= */
  const isEl = (n) => n && n.nodeType === 1;
  const visible = (sel) => { const el = document.querySelector(sel); return !!(el && el.offsetParent !== null); };
  const editorEl = () => document.querySelector(COMPOSER_EDITABLE) || document.querySelector(COMPOSER_FALLBACK_TA) || null;
  function editorEmpty() {
    const el = editorEl();
    if (!el) return true;
    if (el.matches('textarea')) return (el.value || '').replace(/\u200b/g,'').trim().length === 0;
    const txt = (el.textContent || '').replace(/\u200b/g,'').trim();
    return txt.length === 0;
  }
  const isStopVisible = () => visible(STOP_BTN);

  /* =========================
   * FSM + background-safe polling (NO TIMEOUT)
   * ========================= */
  let sid = 0;
  let s = null;
  let pollId = 0;
  const STATE = { IDLE:'IDLE', ARMED:'ARMED', CLEARED:'CLEARED', STREAMING:'STREAMING', DONE:'DONE' };

  function stopPoll() { if (pollId) { clearInterval(pollId); pollId = 0; } }

  function startPoll() {
    stopPoll();
    // steady 250ms poll; browsers may throttle in background which is fine
    pollId = window.setInterval(() => tick(true), 250);
  }

  function cancelSession(reason) {
    if (!s) return;
    log(`CANCEL s#${s.id} (${reason})`);
    stopPoll();
    s = null;
  }

  function arm(reason) {
    // Cancel any previous session (no timeout; avoid multiple active)
    if (s) cancelSession("re-ARM");
    s = {
      id: ++sid,
      state: STATE.ARMED,
      sawStop: false,
      sawCleared: editorEmpty(),
      lastStopGoneAt: 0
    };
    log(`ARM s#${s.id} (${reason}) empty=${s.sawCleared} stop=${isStopVisible()} @ ${t()}`);
    startPoll();
    tick();
  }

  function transition(newState, why) {
    if (!s || s.state === STATE.DONE) return;
    if (s.state !== newState) {
      s.state = newState;
      log(`${newState} s#${s.id} (${why}) empty=${editorEmpty()} stop=${isStopVisible()} @ ${t()}`);
    }
  }

  function evaluate() {
    if (!s || s.state === STATE.DONE) return;

    // Editor cleared after send
    if (!s.sawCleared && editorEmpty()) {
      s.sawCleared = true;
      transition(STATE.CLEARED, "editor cleared");
    }

    // Streaming seen
    if (!s.sawStop && isStopVisible()) {
      s.sawStop = true;
      transition(STATE.STREAMING, "stop visible");
    }

    // Stop disappears
    if (s.sawStop && !isStopVisible() && !s.lastStopGoneAt) {
      s.lastStopGoneAt = performance.now();
      log(`STOP-GONE s#${s.id} (detected)`);
    }

    // Completion: saw Stop once AND Stop gone AND editor empty (150ms stability)
    if (s.sawStop && !isStopVisible() && editorEmpty()) {
      const stable = s.lastStopGoneAt ? (performance.now() - s.lastStopGoneAt) : 999;
      if (stable >= 150) {
        transition(STATE.DONE, "stop gone + editor empty");
        playChime(`s#${s.id}`);
        stopPoll();
        s = null;
      }
    }
  }

  const tick = () => { evaluate(); };

  /* =========================
   * Events & Observers
   * ========================= */
  document.addEventListener("click", (e) => {
    const btn = isEl(e.target) ? e.target.closest(SEND_BTN) : null;
    if (!btn) return;
    arm("send-button click");
  }, true);

  document.addEventListener("keydown", (e) => {
    const ed = isEl(e.target) && (e.target.closest(COMPOSER_EDITABLE) || e.target.closest(COMPOSER_FALLBACK_TA));
    if (!ed) return;
    if (e.key !== "Enter" || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
    if (document.querySelector(SEND_BTN)) arm("keyboard Enter");
  }, true);

  const obs = new MutationObserver((mutations) => {
    if (!s) return;
    for (const m of mutations) {
      if (m.type === "attributes") {
        const el = m.target;
        if (isEl(el) && (el.id === "composer-submit-button" || el.matches(COMPOSER_EDITABLE) || el.matches(COMPOSER_FALLBACK_TA))) {
          tick();
        }
      }
      if (m.type === "childList") {
        for (const n of m.addedNodes) {
          if (!isEl(n)) continue;
          if (n.matches(STOP_BTN) || n.matches(SEND_BTN) ||
              n.querySelector?.(STOP_BTN) || n.querySelector?.(SEND_BTN) ||
              n.matches(COMPOSER_EDITABLE) || n.matches(COMPOSER_FALLBACK_TA) ||
              n.querySelector?.(COMPOSER_EDITABLE) || n.querySelector?.(COMPOSER_FALLBACK_TA)) {
            tick();
            break;
          }
        }
        for (const n of m.removedNodes) {
          if (!isEl(n)) continue;
          if (n.matches(STOP_BTN) || n.matches(SEND_BTN) ||
              n.matches(COMPOSER_EDITABLE) || n.matches(COMPOSER_FALLBACK_TA)) {
            tick();
            break;
          }
        }
      }
      if (m.type === "characterData") tick();
    }
  });

  function start() {
    obs.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["data-testid","id","class","style","contenteditable","value"],
      characterData: true
    });
    log("armed (composer FSM, background-safe, no-timeout). Completes on: saw Stop → Stop gone + editor empty.");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", start, { once: true });
  } else {
    start();
  }
})();

Go actual full screen on Simplepractice.com video calls

When I’m on calls with my therapist it has bugged me that I can’t actually go fullscreen with the video so I gave CGPT the code for the page and wrote up a script that expands the fullscreen video to actually be fullscreen.

Use either the console script or Tampermonkey script and try it out for yourself!

Console script

(() => {
  const TOGGLE = 'tm-full-bleed-active';
  const ROOT_SEL = 'div.bbwWj.video';
  const PANEL_SEL = `${ROOT_SEL} > div.mljTO`;
  const STREAM_CONTAINER_SEL = `${ROOT_SEL} .stream-container`;
  const PARTICIPANT_SEL = `${ROOT_SEL} .participant-container`;
  const VIDEO_SEL = `${ROOT_SEL} video`;

  // --- style injection ---
  const css = `
    html.${TOGGLE}, body.${TOGGLE}{margin:0!important;padding:0!important;overflow:hidden!important}
    html.${TOGGLE} ${PANEL_SEL}{
      position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;
      margin:0!important;padding:0!important;border:0!important;background:#000!important;
      z-index:2147483647!important;box-shadow:none!important
    }
    html.${TOGGLE} ${STREAM_CONTAINER_SEL}, html.${TOGGLE} ${PARTICIPANT_SEL}{
      position:absolute!important;inset:0!important;width:100%!important;height:100%!important;
      margin:0!important;padding:0!important;border:0!important;max-width:none!important;max-height:none!important;overflow:hidden!important;background:transparent!important
    }
    html.${TOGGLE} ${VIDEO_SEL}{
      position:absolute!important;inset:0!important;width:100%!important;height:100%!important;
      object-fit:cover!important;object-position:center center!important;display:block!important;background:#000!important;border:0!important;box-shadow:none!important;transform:none!important
    }
    html.${TOGGLE} ${ROOT_SEL} .name, html.${TOGGLE} ${ROOT_SEL} .pin-button, html.${TOGGLE} ${ROOT_SEL} .more-button{
      display:none!important;visibility:hidden!important;pointer-events:none!important
    }
    .tm-fb-btn{
      position:fixed!important;right:14px;bottom:14px;z-index:2147483647!important;
      font:600 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
      padding:10px 12px;border-radius:999px;background:rgba(0,0,0,.6);color:#fff;
      border:1px solid rgba(255,255,255,.2);cursor:pointer;user-select:none;backdrop-filter:blur(4px)
    }
    .tm-fb-btn:hover{background:rgba(0,0,0,.75)}
  `;
  let style = document.getElementById('tm-fb-style');
  if (!style) {
    style = document.createElement('style');
    style.id = 'tm-fb-style';
    style.textContent = css;
    document.head.appendChild(style);
  }

  // --- helpers ---
  const $ = (s, r = document) => r.querySelector(s);
  const nap = (ms) => new Promise(r => setTimeout(r, ms));
  const waitFor = async (sel, tries = 200, step = 100) => {
    while (tries-- > 0) { const el = $(sel); if (el) return el; await nap(step); }
    return null;
  };

  // --- state ---
  let btn;

  const isActive = () =>
    document.documentElement.classList.contains(TOGGLE) ||
    document.body.classList.contains(TOGGLE);

  const setActive = (on) => {
    [document.documentElement, document.body].forEach(el => el && el.classList.toggle(TOGGLE, on));
    if (btn) btn.textContent = on ? 'Exit Full-Bleed' : 'Full-Bleed';
  };

  const toggle = () => setActive(!isActive());

  // --- UI button ---
  const mountButton = () => {
    if (btn && document.body.contains(btn)) return btn;
    btn = document.createElement('button');
    btn.className = 'tm-fb-btn';
    btn.type = 'button';
    btn.textContent = isActive() ? 'Exit Full-Bleed' : 'Full-Bleed';
    btn.addEventListener('click', toggle);
    document.body.appendChild(btn);
    return btn;
  };

  // --- key bindings ---
  const bindKeys = () => {
    window.addEventListener('keydown', (e) => {
      if (e.key === 'f' || e.key === 'F') { e.preventDefault(); toggle(); }
      else if (e.key === 'Escape' && isActive()) { e.preventDefault(); setActive(false); }
    }, { capture: true });
  };

  // --- dblclick on video ---
  const bindVideoDblClick = (v) => {
    if (!v || v.dataset.tmFbBound) return;
    v.addEventListener('dblclick', (e) => { e.preventDefault(); toggle(); }, { capture: true });
    v.dataset.tmFbBound = '1';
  };

  // --- observe dynamic DOM ---
  const observe = () => {
    const mo = new MutationObserver(() => {
      const v = $(VIDEO_SEL);
      if (v) bindVideoDblClick(v);
      if (!btn && document.body) mountButton();
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  };

  // --- bootstrap ---
  (async () => {
    await waitFor('body');
    bindKeys();
    observe();
    const v = await waitFor(VIDEO_SEL, 120, 150);
    if (v) bindVideoDblClick(v);
    mountButton();

    // Expose controls for manual use
    window.tmFullBleed = { toggle, on: () => setActive(true), off: () => setActive(false) };
    console.log('[tmFullBleed] Ready. Use tmFullBleed.toggle(), press F, double-click video, or use the button.');
  })();
})();

Tampermonkey script

// ==UserScript==
// @name         Full-Bleed Video (SimplePractice only)
// @namespace    nic.tools.video
// @version      1.0.1
// @description  Force main video to go true full-bleed (edge-to-edge) on SimplePractice video
// @author       you
// @match        https://video.simplepractice.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';

  // ---------- small utils ----------
  const nap = (ms) => new Promise(r => setTimeout(r, ms));
  const q = (sel, root = document) => root.querySelector(sel);

  // Wait for an element or return null after tries*interval
  const waitFor = async (fnOrSel, tries = 200, interval = 100) => {
    while (tries-- > 0) {
      let el = null;
      try { el = typeof fnOrSel === 'function' ? fnOrSel() : q(fnOrSel); } catch {}
      if (el) return el;
      await nap(interval);
    }
    return null;
  };

  // ---------- selectors from provided markup ----------
  const ROOT_SEL = 'div.bbwWj.video';
  const PANEL_SEL = `${ROOT_SEL} > div.mljTO`;
  const STREAM_CONTAINER_SEL = `${ROOT_SEL} .stream-container`;
  const PARTICIPANT_SEL = `${ROOT_SEL} .participant-container`;
  const VIDEO_SEL = `${ROOT_SEL} video`;

  const TOGGLE_CLASS = 'tm-full-bleed-active';

  // ---------- inject styles ----------
  GM_addStyle(`
    /* Full-bleed mode root locks */
    html.${TOGGLE_CLASS}, body.${TOGGLE_CLASS} {
      margin: 0 !important;
      padding: 0 !important;
      overflow: hidden !important;
    }

    /* Pin the main panel to the viewport */
    html.${TOGGLE_CLASS} ${PANEL_SEL} {
      position: fixed !important;
      inset: 0 !important;
      width: 100vw !important;
      height: 100vh !important;
      margin: 0 !important;
      padding: 0 !important;
      border: 0 !important;
      background: #000 !important;
      z-index: 2147483647 !important;
      box-shadow: none !important;
    }

    /* Ensure no intermediate wrapper constrains size */
    html.${TOGGLE_CLASS} ${STREAM_CONTAINER_SEL},
    html.${TOGGLE_CLASS} ${PARTICIPANT_SEL} {
      position: absolute !important;
      inset: 0 !important;
      width: 100% !important;
      height: 100% !important;
      margin: 0 !important;
      padding: 0 !important;
      border: 0 !important;
      max-width: none !important;
      max-height: none !important;
      overflow: hidden !important;
      background: transparent !important;
    }

    /* Make the video fill and crop (letterboxing avoidance) */
    html.${TOGGLE_CLASS} ${VIDEO_SEL} {
      position: absolute !important;
      inset: 0 !important;
      width: 100% !important;
      height: 100% !important;
      object-fit: cover !important;
      object-position: center center !important;
      display: block !important;
      background: #000 !important;
      border: 0 !important;
      box-shadow: none !important;
      transform: none !important;
    }

    /* Hide overlays that might intrude (adjust as needed) */
    html.${TOGGLE_CLASS} ${ROOT_SEL} .name,
    html.${TOGGLE_CLASS} ${ROOT_SEL} .pin-button,
    html.${TOGGLE_CLASS} ${ROOT_SEL} .more-button {
      display: none !important;
      visibility: hidden !important;
      pointer-events: none !important;
    }

    /* On-screen toggle button */
    .tm-fb-btn {
      position: fixed !important;
      right: 14px;
      bottom: 14px;
      z-index: 2147483647 !important;
      font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
      padding: 10px 12px;
      border-radius: 999px;
      background: rgba(0,0,0,0.6);
      color: #fff;
      border: 1px solid rgba(255,255,255,0.2);
      cursor: pointer;
      user-select: none;
      backdrop-filter: blur(4px);
    }
    .tm-fb-btn:hover { background: rgba(0,0,0,0.75); }
  `);

  // ---------- toggle logic ----------
  const setActive = (on) => {
    const rootList = [document.documentElement, document.body].filter(Boolean);
    rootList.forEach(el => el.classList.toggle(TOGGLE_CLASS, on));
    if (btn) btn.textContent = on ? 'Exit Full-Bleed' : 'Full-Bleed';
  };

  const isActive = () =>
    document.documentElement.classList.contains(TOGGLE_CLASS) ||
    document.body.classList.contains(TOGGLE_CLASS);

  const toggle = () => setActive(!isActive());

  // ---------- button ----------
  let btn;
  const mountButton = () => {
    if (btn && document.body.contains(btn)) return btn;
    btn = document.createElement('button');
    btn.className = 'tm-fb-btn';
    btn.type = 'button';
    btn.textContent = isActive() ? 'Exit Full-Bleed (f)' : 'Full-Bleed (f)';
    btn.addEventListener('click', toggle);
    document.body.appendChild(btn);
    return btn;
  };

  // ---------- event bindings ----------
  const bindKeys = () => {
    window.addEventListener('keydown', (e) => {
      if (e.key === 'f' || e.key === 'F') {
        e.preventDefault();
        toggle();
      } else if (e.key === 'Escape' && isActive()) {
        e.preventDefault();
        setActive(false);
      }
    }, { capture: true });
  };

  const bindVideoDoubleClick = (video) => {
    if (!video || video.dataset.tmFbBound) return;
    video.addEventListener('dblclick', (e) => {
      e.preventDefault();
      toggle();
    }, { capture: true });
    video.dataset.tmFbBound = '1';
  };

  // ---------- observer to reapply on dynamic pages ----------
  const observe = () => {
    const mo = new MutationObserver(() => {
      const video = q(VIDEO_SEL);
      if (video) bindVideoDoubleClick(video);
      if (!btn && document.body) mountButton();
    });
    mo.observe(document.documentElement, { childList: true, subtree: true, attributes: false });
  };

  // ---------- bootstrap ----------
  (async () => {
    await waitFor(() => document.body);
    bindKeys();
    observe();

    const video = await waitFor(VIDEO_SEL, 120, 150);
    if (video) bindVideoDoubleClick(video);

    mountButton();

    // Optional: expose console controls
    window.tmFullBleed = { toggle, on: () => setActive(true), off: () => setActive(false) };
    console.log('[tmFullBleed] Ready on SimplePractice. Use tmFullBleed.toggle(), press F, double-click video, or use the button.');
  })();

})();

See my creation conversation with ChatGPT here:

https://chatgpt.com/share/68c89780-f700-8007-916b-2d707b1970a6

Fix for audio cutting out for 1-3 seconds randomly with MSI B650-S Wifi Motherboard

If your sound keeps cutting out seemingly randomly for your wirelessly headset with this motherboard then try plugging it into the other USB slots (not the blue ones, the red ones). It seems to have fixed my issue. Seems the other ones may not have enough power to support the power draw.

Found the fix here: Audio issues MSI Pro B650-S Wifi | MSI Global English Forum

Have you use another headphone to check for the symptom?

I think I found the solution to my issue :

The USB 3.2 Gen 1 ports connect to something called “Hub-1074” and I guess those aren’t good enough for USB headsets. I at first thought that it was an issue with the board because the chipset driver updates reduced the problem, but didn’t fix it. I then tried connecting with a different headset that uses 3.5mm jacks and it worked without issues, so now I have connected my USB headset to one of the USB 3.2 Gen 2 ports that connect to the CPU, and so far I haven’t had any audio issues.

My Favourite Podcasts!

Podcasts are one of my favourite ways to learn and entertain myself. Here are some of my faves from over the years.

  • Planet Money / The Indicator
    • Fairly light podcast on economics, very engaging hosts, interesting topics.
  • This American Life
    • A classic, beautiful stories on being human.
  • 99% Invisible
    • Learn awesome things about design and little details in life you wouldn’t have otherwise discovered.
  • Rumble Strip
    • Very direct, down to earth stories about everyday Vermonters. Especially love the ones with her farmer friend or town hall meetings. Note, I hate about 25% of the episodes as she’ll have random guest episodes and they can be awful.
  • What Roman Mars Can Learn About Con Law
    • Get into the minutia of constitutional law with Roman Mars and his law professor friend.
  • Twenty Thousand Hertz
    • The most beautifully podcast I’ve ever listened to. Amazing stories about audio in many different ways and a surprisingly genuine and warm. host.
  • The Big Dig
    • Ever wanted to find out exactly what went wrong in so many ways with a mega project in the United States? This podcast goes into the details on the many trials and tribulations of the Big Dig project in Boston. I learned a lot and it was super interesting!
  • Acquired
    • Listen to this if you’re interested in going in deep on companies. The hosts go on multi hour deep dives into the histories of the companies they cover. I find it to be super helpful for understanding how companies came to be but also what differentiates them from other companies.
  • Search Engine
    • In depth episodes on a very wide range of topics (the secret pool in Buckingham Palace, people who buy luggage at the airport, jawmaxxing) which I find very interesting and delightful in their unnecessary depth.
  • The Sporkful
    • Nice stories about food. The host’s speaking style isn’t my favourite and some of the episodes are a little meh but there are enough good ones in there to make it worthwhile.
  • Startup
    • Front row seats to creation and development over the years of Gimlet. Really fascinating glimpse into the birth of a company (and all the various things that come with it).
  • Reply All
    • One of Gimlet’s first successful podcasts, now retired due to a messy series of events but lots of fun and interesting episodes before that happened.
  • Heavyweight
    • Heartwarming and humanistic stories about the host trying to help a given guest try and resolve a deep seated relationship issue. Personally, I find the non-mainline episodes annoying but the standard episodes are great!
  • More Perfect
    • An awesomely in depth podcast series on how the Supreme Court works and some of their historic decisions. If you have any interest in the way the American legal system functions I wholeheartedly recommend this podcast.
  • The Disconnect
    • Want to spend 10+ hours learning about how Texas’s energy grid functions and why it failed? This is the podcast for you, :).
  • The End of the World (with Josh Clark)
    • Quite different from most podcasts out there. This one touches on a number of far far future scenarios and feels very sci fi at times. Excellent production and you’ll probably learn a few new concepts.
  • The Last Cup
    • Detailed storytelling surrounding Messi’s final world cup run and all the drama leading up to it. As a fan of Messi this was great to listen to plus it has a very satisfying end.
  • Gamecraft
    • One of Benchmark’s legendary GPs breaks down the economics and structure of the gaming industry piece by piece. Incredible knowledge contained within. Great if you’re interested in the business of gaming.
  • The Secret Life of Canada
    • Stories told about the often untold side of things from a indigenous perspective in Canada. A mix of humour and learning about horrific events you’ll likely learn a lot about things you didn’t know happened. The hosts have excellent chemistry with each other and do a fantastic job of covering the subject matter in a way that doesn’t shy away from the facts but also doesn’t leave you feeling irredeemably depressed.
  • Lenny’s Podcast
    • The best product podcast out there. Learn about a whole range of product related topics and from some of the strongest operators out there. When I feel like learning more about my craft I listen to this, :).
  • Containers
    • In depth coverage of the systems that power our ports (and how they’ve changed over time). The storytelling here is excellent and you’ll learn a lot about how goods move around the world.