Debugging a hidden checkbox on Medium's real UI
A small backlog item: make blog-manager’s Medium import bridge turn on the Partner Program paywall by default, since I’m enrolled and canonical/imported posts are fully eligible to earn. Should have been a one-line fix. It turned into an afternoon of live debugging against a real Medium account, because the checkbox I needed to click doesn’t behave like a normal checkbox at all.
Finding the checkbox
medium-bridge doesn’t talk to a Medium API for posting - there isn’t one worth using anymore - it drives Medium’s real web UI with Playwright, the same way a human would click through the “Import a story” flow. So “turn on metering by default” meant: find the actual DOM element for the paywall toggle in Medium’s pre-publish panel, and click it before confirming.
I didn’t want to guess a selector from docs and hope it matched the live page - Medium’s own UI text doesn’t even match its own help center consistently (some pages say “meter my story,” others say “paywall your story”). So I wrote a small diagnostic script, copied it into the running production container, and ran it against a real draft to dump every checkbox in the pre-publish panel. Found it immediately: a checkbox literally labeled “Paywall this story,” unchecked by default. Good, straightforward.
Except when I tried to click it, nothing happened. Dumping its computed style explained why: width: 0px, height: 0px, opacity: 0. The real input is invisible and zero-sized, sitting behind a custom-styled toggle switch that’s actually rendered. Playwright’s default click behavior requires an element to have a non-zero bounding box before it’ll interact with it - a totally reasonable safety check that exists to stop you from clicking something a real user could never see, and exactly the check standing between me and this checkbox.
The checkbox that lies about being disabled
Once I switched to checking for DOM presence instead of visibility, a second problem showed up: the checkbox is disabled for a few seconds right after import, then becomes enabled once Medium finishes some async eligibility check server-side. I confirmed this by polling the disabled attribute across several live test imports - the window varied from basically instant to several seconds, never a fixed delay. A disabled input silently ignores click and check calls, even with Playwright’s force: true flag, so trying too early doesn’t error, it just quietly does nothing. My first few test runs looked like the fix wasn’t working at all, until I realized I was checking before Medium had actually finished deciding whether the post was eligible.
The fix: poll the disabled attribute for up to 15 seconds before attempting anything, and only proceed once it clears. Also worth noting for anyone chasing similar bugs: across every clean test run once I had the polling right, the checkbox settled to already checked by the time it became enabled. I still don’t know for certain whether that’s Medium’s actual default for eligible accounts or something specific to my account’s history - but the code doesn’t assume either way. It checks the current state and only clicks if it’s still unchecked, so it’s correct regardless of what Medium’s real default turns out to be.
Verifying against a real account, carefully
The only way to know any of this actually works is to run it against the real, authenticated Medium session - there’s no staging environment for medium-bridge, no sandbox account. That meant running real imports against real blog posts, which creates real (if harmless, deletable) drafts, and eventually a real publish to confirm the metering setting actually took effect on a live story.
I made a point of keeping each step as small and reversible as possible: import-only first (creates a draft, no publish), open the pre-publish panel and dump its state without ever clicking confirm, and only run a full publish once I was confident the logic was right. When I did publish, I fetched the live story page afterward and looked for Medium’s own “Member-only story” badge - real, external, ground-truth confirmation instead of trusting my own code’s log output.
I also caught myself twice about to reach for a more invasive tool than the one I’d decided this task warranted - same underlying action, slightly different wrapper the second time. Worth naming: “let me just tweak the parameters and retry” is exactly the instinct that turns a reasonable approach into working around a boundary you just set for yourself. Stopping and re-deciding plainly was the right call both times.
What the automated review caught
Once the happy path worked, I ran this through the same review pass I use for regular PRs. Three independent review angles flagged the same two bugs, which is a good signal they were real: a waitFor that could never actually run because an earlier count() check would already have returned early, and a fallback click that could undo a checkbox that had actually already been successfully checked (Playwright’s check() clicks and then separately asserts the result - it can throw on that assertion even when the click itself worked, and my fallback logic didn’t account for that). Both are the kind of thing that’s easy to miss reading your own code once, because the happy path masks them completely.
What’s next
Nothing pending - this is deployed and verified. I did leave six throwaway drafts sitting in the Medium account from testing, which I couldn’t clean up automatically (my selector guesses for Medium’s delete-draft menu didn’t land, and it wasn’t worth more live production interaction to chase down for a cosmetic cleanup task). Manual deletion whenever it’s convenient.
Related reading
Medium kept dropping my hero image, so the bridge now pastes it in
Two theories disproved at one manual import each, a paste that saved dimensions but no src, and the native file-chooser path that finally attached the image.
The blog that couldn't publish on a schedule
A batch pipeline design that started with a five-minute substrate check: future-dated posts weren't hidden, they were 404s, and nothing ever rebuilt the site.
A per-blog toggle, and when not to "fix" a bug
A boolean threaded through four layers, and three independent review passes agreeing on a finding I deliberately didn't fix - because the cache is the dedup mechanism.