<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Yuriy Stasyuk]]></title><description><![CDATA[Yuriy Stasyuk]]></description><link>https://ystasyuk.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 17:14:34 GMT</lastBuildDate><atom:link href="https://ystasyuk.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Cover Style Library by Hand-Checking Thumbnails: Build Notes From Cover Styles]]></title><description><![CDATA[Cover Styles is a free site I made that lists 247 book cover styles. Each style has real reference covers and a ready prompt you can send to an image AI…]]></description><link>https://ystasyuk.hashnode.dev/building-a-cover-style-library-by-hand-checking-thumbnails-build-notes-from-cover-styles</link><guid isPermaLink="true">https://ystasyuk.hashnode.dev/building-a-cover-style-library-by-hand-checking-thumbnails-build-notes-from-cover-styles</guid><category><![CDATA[Python]]></category><category><![CDATA[webdev]]></category><category><![CDATA[AI]]></category><category><![CDATA[buildinpublic]]></category><dc:creator><![CDATA[Yuriy Stasyuk]]></dc:creator><pubDate>Sat, 31 Oct 2026 17:14:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/aeb84971-b2a8-4475-98bb-8faccdba6f11.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cover Styles is a free site I made that lists 247 book cover styles. Each style has real reference covers and a ready prompt you can send to an image AI. Live at <a href="https://covers.yuriystasyuk.com">https://covers.yuriystasyuk.com</a>.</p>
<p>I'm skipping the "why" here (short version: a self-publishing friend kept telling me AI covers are always bland). These are notes on how the library itself got built, since that was most of the work.</p>
<h2>Step one was a text file</h2>
<p>Before any code, there was a file called <code>taxonomy.txt</code>. One style per line, pipe-delimited:</p>
<pre><code>id | era | category | name | definition | seed hints (optional, verify)
</code></pre>
<p>A real line looks like this:</p>
<pre><code>giant-type-fills-cover|modern|Typographic|Giant Type Fills the Cover|Title set so large it occupies most of the cover, often broken across lines, minimal or no image|A Little Life? Fleishman Is in Trouble; Normal People US
</code></pre>
<p>The question marks in the seed hints are on purpose. They're guesses about which famous books might show the style, and the header says it right there: verify. The playbook rule was to check each one before it went in.</p>
<p>The seed list started around two hundred styles. It ended at 247, and 28 of the final styles say in their notes that they absorbed or merged an older one. For example, a "Personal Finance Bold Money" style got folded into Giant Type Fills the Cover because the covers looked the same once they were side by side.</p>
<h2>A playbook, because the cataloguing ran in batches</h2>
<p>Filling in a style means finding real covers, saving thumbnails, and writing up what the style actually is. I wrote a playbook for the AI agents doing that work in batches, and it ended up being the most useful file in the repo. The main rules:</p>
<ul>
<li>10 to 20 real covers per style, aiming for 12 to 15. Famous or award-winning books preferred.</li>
<li>For modern styles, editions from 2006 to 2026. A recent reissue of an old book counts.</li>
<li>Max three books from one author or series, unless the style is a series design.</li>
<li>The edition matters. The right cover is one specific printing, so search by ISBN when possible.</li>
</ul>
<p>The final spread is 10 to 25 covers per style, median 14.</p>
<h2>Where the images came from</h2>
<p>A small Python script, <code>covertool.py</code>, does the fetching. It can grab a cover by ISBN from Open Library (falling back to an Amazon image by ISBN-10), by Open Library cover id, or from any direct URL. Every image gets converted to WebP at quality 78 and shrunk to 400px tall max. It also throws away anything under 60px, because Open Library hands back a 1x1 placeholder when it has no cover.</p>
<p>Sources shifted mid-run. Open Library's search started returning 502s through the proxy, Goodreads blocked now and then, and the Apple iTunes Search API turned out to be the reliable one. Its ebook art usually matches the print jacket, and you can swap <code>100x100bb</code> for <code>600x900bb</code> in the artwork URL to get a bigger image. There's also a local catalog of about 1,200 designer-picked covers from 2015 to 2025, pulled from Literary Hub's best-covers roundups, with designer credits in the captions and searchable by regex.</p>
<p>Counting by source URL in the final data, a bit over half the covers came through Goodreads' image CDN, then Open Library, then the LitHub catalog and Apple. 1,337 of the covers have a designer credit.</p>
<h2>The check that mattered most: look at the picture</h2>
<p>Metadata lies. An ISBN can return the UK edition, a movie tie-in, a blank placeholder, or a totally different book. So the playbook made one step mandatory: <code>covertool.py sheet &lt;style&gt;</code> builds a contact sheet of every thumbnail in a style, and someone has to actually look at it. Anything wrong gets deleted and replaced, then the sheet gets rebuilt and checked again.</p>
<p>The style write-up also had to be based on what's in the contact sheet, not on what the style is "supposed" to look like. That one rule did more for prompt quality than anything else, because the descriptions came from real covers instead of vibes.</p>
<h2>What a style record holds</h2>
<p>Each style is a <code>style.json</code> with a fixed shape: summary, genres, designers, and a <code>visual_dna</code> block covering composition, typography, palette, imagery, texture and mood. Then do and don't lists, and the prompt fields.</p>
<p>The prompt template uses placeholders: <code>{TITLE}</code>, <code>{AUTHOR}</code>, <code>{SUBJECT}</code>, <code>{GENRE}</code> and optionally <code>{PALETTE}</code>. Two rules for templates:</p>
<ul>
<li>Front cover only, portrait 2:3 (1600x2400), title and author spelled exactly.</li>
<li>Never name a real book, designer or artist inside the prompt. Describe the look instead.</li>
</ul>
<p>That second rule keeps the prompts usable for anyone's book and avoids asking a model to copy a specific person's work.</p>
<p>Each style also has a negative prompt. The romantasy one starts with "misspelled text, extra words, gibberish lettering, watermark, 3D book mockup" and gets more specific from there.</p>
<p><code>validate.py</code> checks every style for required fields, valid JSON, and that every image file appears in the examples list and the other way around.</p>
<h2>Then a prompt for every single cover</h2>
<p>A style prompt describes the whole family of covers, but sometimes you love one particular cover in it. So there's a second pass: a prompt per reference cover, stored as <code>library/per_cover/&lt;style&gt;.json</code>, a map from image filename to prompt. A merge script copies them into the site data and prints how many covers got one. It reads 3,642 of 3,642 now.</p>
<h2>Static pages from the same data</h2>
<p>The app is one page, but each style also gets a prerendered page at <code>/style/&lt;id&gt;/</code> so search engines can read it. <code>build_style_pages.py</code> writes them from <code>styles.json</code> and nothing else, so there's no hand-written copy to drift. Then <code>validate_site.py</code> fails the build if the page count doesn't match the style count, if any two pages share a title or description, if a page has more than one H1, or if a page is missing from the sitemap. It runs on every push in GitHub Actions before the Cloudflare Pages deploy.</p>
<h2>What I'd tell someone building a similar library</h2>
<p>Write the rules down before you collect anything. Look at the images yourself. Keep one data file as the source and generate everything else from it. And let the build fail loudly when the counts don't line up, because a quiet mismatch between data and pages is the kind of bug nobody notices for months.</p>
<p>The library is free at <a href="https://covers.yuriystasyuk.com">https://covers.yuriystasyuk.com</a> if you want to poke at it.</p>
]]></content:encoded></item><item><title><![CDATA[My Periodic Table Card Game Started With Four Types. It Needed Nine.]]></title><description><![CDATA[How I sorted all 118 elements into a fair type chart for Elemon, kept the matchups honest about which ones are real chemistry, and tested the balance with…]]></description><link>https://ystasyuk.hashnode.dev/my-periodic-table-card-game-started-with-four-types-it-needed-nine</link><guid isPermaLink="true">https://ystasyuk.hashnode.dev/my-periodic-table-card-game-started-with-four-types-it-needed-nine</guid><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[chemistry]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Yuriy Stasyuk]]></dc:creator><pubDate>Thu, 29 Oct 2026 17:25:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/a59a6c8d-bf37-4503-aef8-94f9a64b0180.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've ever played a monster card game, you know the type chart is the whole game. Fire beats grass, water beats fire, and a kid who knows the chart wins more. My sons can recite those charts in their sleep.</p>
<p>When we started building Elemon, a free browser card game where every element is a monster, the type chart was the part I worried about most. Every one of the 118 elements needed exactly one type. The types had to come from the real periodic table. And the matchups had to be fair enough that a kid picking sodium has the same shot as a kid picking iron.</p>
<p>I got it wrong the first time. Here's how the chart went from four types to nine, and what I did to keep it true.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/be25e7c8-e5bf-4f79-8098-a028fc034282.png" alt="" style="display:block;margin:0 auto" />

<p><em>The three starters: chlorine, sodium and iron. Each one is a different type.</em></p>
<h2>Version one was a neat little circle</h2>
<p>The earlier rules file had four types in a loop. Salt beat Soft, Soft beat Life, Life beat Hard, and Hard beat Salt. Salt was the halogens, Soft was the reactive metals of groups 1 and 2, Life was the nonmetals and metalloids, and Hard was everything else.</p>
<p>It was easy to explain. The trouble was "everything else." Hard ended up with 86 of the 118 cards. It held the transition metals, the noble gases, the lanthanides, the actinides and the lab-made superheavies all at once. Helium, a gas that barely reacts with anything, was on the same team as aluminum. A kid could win every match with that chart and still learn nothing about where helium lives on the table.</p>
<h2>Version two follows the table's neighborhoods</h2>
<p>The current chart has nine types, and each one is a region you can point to on a printed periodic table:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>What it is</th>
<th>Cards</th>
</tr>
</thead>
<tbody><tr>
<td>Blast</td>
<td>Alkali and alkaline earth metals, groups 1 and 2</td>
<td>12</td>
</tr>
<tr>
<td>Life</td>
<td>H, C, N, O, P, S, Se</td>
<td>7</td>
</tr>
<tr>
<td>Acid</td>
<td>Halogens F, Cl, Br, I, At</td>
<td>5</td>
</tr>
<tr>
<td>Noble gas</td>
<td>He, Ne, Ar, Kr, Xe, Rn</td>
<td>6</td>
</tr>
<tr>
<td>Crystal</td>
<td>Metalloids B, Si, Ge, As, Sb, Te</td>
<td>6</td>
</tr>
<tr>
<td>Metal</td>
<td>Every other metal</td>
<td>37</td>
</tr>
<tr>
<td>Rare Earth</td>
<td>Lanthanides</td>
<td>15</td>
</tr>
<tr>
<td>Nuclear</td>
<td>Actinides</td>
<td>15</td>
</tr>
<tr>
<td>Synthetic</td>
<td>Elements 104 to 118</td>
<td>15</td>
</tr>
</tbody></table>
<p>In code it's one small function that takes the atomic number and returns a type. Superheavies get checked first, then the lanthanide and actinide ranges, then short lists for the other families, and anything left over is Metal.</p>
<p>Checking superheavies first settles a real edge case. Tennessine, element 117, sits in the halogen column, and in the four-type version it was a Salt card. But it's a lab-made atom that falls apart almost instantly, so now it battles as Synthetic, and astatine is the last Acid. I'd rather have one clear rule a kid can learn ("104 and up is Synthetic") than a footnote.</p>
<h2>Every matchup says whether it's real</h2>
<p>This was the rule I cared about most. If the game says Blast beats Life, a kid will believe it, so the reason had better be true.</p>
<p>Each matchup in the rules file carries a <code>real</code> flag and a one-line reason. Most of them are real chemistry. Blast beats Life because reactive metals react violently with water, and living things are mostly water. Acid beats Metal because halogens corrode many metals. Rare Earth beats Metal because rare-earth magnets pull hard on iron. Metal beats Nuclear because dense metals like lead shield against radiation.</p>
<p>A few matchups are there for balance, and I didn't want to dress them up as science. Metal beating Crystal ("brittle crystals shatter against metal") and Crystal beating Noble gas ("precision pierces evasion") are game rules, and the Elemondex and the types guide label them that way. A kid can learn the chart and also learn which parts are pretend.</p>
<p>Synthetic got the strangest treatment. Every Synthetic hit rolls half, normal or double damage, and nothing is strong or weak against it. The rolls come from the match's own seed, so a replay of the same match plays out the same way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/57e53941-bdbe-48f2-94d0-5c73a127fb2d.png" alt="" style="display:block;margin:0 auto" />

<p><em>Sodium's stats come from real properties. Toughness 7 because you can cut it with a knife, Power +3 because it explodes in water.</em></p>
<h2>Stats from chemistry, on a budget</h2>
<p>Types decide who hits whom for double. Stats decide everything else, and I wanted those to be real too.</p>
<p>Each element gets three stats. Toughness comes from density, hardness and stability. Power comes from how eagerly it reacts. Speed comes from how light the atom is. A build script scores all 118 elements on each one, ranks them, and gives every element the same 6-point budget, so its best stat is a 3 and its worst is a 1.</p>
<p>That budget kept the chart from collapsing into "pick the strongest thing." Caesium hits as hard as anything in the game and goes down fast. Osmium, the densest element, can take a beating but barely hits back. Sodium shows up as Toughness 7, Power +3, and each number carries a plain reason a kid can read ("So soft you can cut it with a knife," "Explodes in water").</p>
<p>Some of those facts are pinned down in tests. One test checks that osmium lands in the top Toughness tier, hydrogen in the top Speed tier, caesium in the top Power tier, and oganesson in the bottom Toughness tier. If I ever tweak a formula and osmium turns squishy, the test fails.</p>
<h2>Balancing with bots</h2>
<p>I can't playtest nine types by hand. So there's a simulation script that plays the computer trainer against itself on random balanced teams and reports win rates by type, stat shape, move and seat.</p>
<p>The October run looked like this for 3,000 Normal vs Normal games. Every type won between 47.7% and 54.3% of its games, with Nuclear at the top and Noble gas at the bottom. The first seat won 50.0%. Matches ran about 16 to 17 rounds with no stalls. Rare Earth runs a little low when both sides play on Hard, around 46%.</p>
<p>Solo practice got one deliberate tilt. On Easy and Normal, the kid's cards get 2 extra HP against the computer. Friend matches stay even.</p>
<h2>Keeping the move facts honest</h2>
<p>Every card has three moves, and every move has a one-sentence "why" that's a true fact about the element. Sodium's Salt Bond is real because sodium and chlorine make table salt. Its Orange Lamp move is the glow of old sodium street lamps.</p>
<p>The game reads each move's name to give it a job. A move with "Shield" in it guards, a move with "Fuel" charges up. The three jobs on one card are always different, and a test checks that for all 118.</p>
<p>Facts still slip through. One of my commits from October just says "fix nitrogen fact (78% of the air)." Another one removes some claims about mixing elements that the game didn't back up. The site has a contact page, and if a kid or a teacher spots a wrong fact, I fix the data file and every page that uses it rebuilds from there.</p>
<h2>Where it runs</h2>
<p>Elemon is a static site on Cloudflare Pages with a small Worker that acts as referee for matches. The rules engine lives in the Worker, and the browser draws the table and sends moves. There's no account, no download and no ads, and there's no chat between players, only game moves.</p>
<p>If you want to see the type chart in action, it's at <a href="https://elemon.win">elemon.win</a>. Tap a type on the home page and it shows you who it beats and why.</p>
]]></content:encoded></item><item><title><![CDATA[My Pregnancy Food Pages Said "Completely Safe." Here's How I Fixed Them]]></title><description><![CDATA[Building PregNut's food safety notes so every claim carries its own source, and the copy stops promising things nobody can promise.]]></description><link>https://ystasyuk.hashnode.dev/my-pregnancy-food-pages-said-completely-safe-here-s-how-i-fixed-them</link><guid isPermaLink="true">https://ystasyuk.hashnode.dev/my-pregnancy-food-pages-said-completely-safe-here-s-how-i-fixed-them</guid><category><![CDATA[webdev]]></category><category><![CDATA[health]]></category><category><![CDATA[data]]></category><category><![CDATA[writing]]></category><dc:creator><![CDATA[Yuriy Stasyuk]]></dc:creator><pubDate>Tue, 27 Oct 2026 17:24:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/1991e094-af26-4179-8305-138e7c42c26a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>PregNut started in 2018 as a spreadsheet for my wife's first pregnancy. Today it's a site with a page for each of its 1,272 foods, and every one of those pages has a short "In pregnancy" note next to the nutrient numbers. Those notes are the riskiest words on the site. A pregnant person might read one at the grocery store and decide what to buy.</p>
<p>This post is about how I write and check those notes, and about the batch I got wrong.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/50055b18-8e2d-4f5f-baef-cf528ecacd92.png" alt="A food page. The numbers are the easy part. The note underneath is where you can hurt someone." style="display:block;margin:0 auto" />

<p><em>A food page. The numbers are the easy part. The note underneath is where you can hurt someone.</em></p>
<h2>The first version read too confident</h2>
<p>The first round of food notes was generated in batches, one JSON file per batch, and compiled into a single file the site reads at build time. Each entry had a verdict label like "Recommended" or "Limit," a few sentences of explanation, and a list of sources for the whole entry.</p>
<p>They read fine. Clean, friendly, sure of themselves. Too sure. When I audited the site this October, I kept tripping over phrases like "completely safe," "no risk," and cooking that "eliminates" the risk. Dozens of food pages had some version of it.</p>
<p>Nobody can honestly say a food is completely safe in pregnancy. The CDC and FDA guidance I was citing doesn't say that either. It talks about safer and riskier choices, temperatures, washing, pasteurization. My copy had rounded careful advice up into a promise.</p>
<h2>Sources per claim, not per page</h2>
<p>The fix changed the data shape. Each entry now has a <code>claims</code> array, and each claim is a sentence or two with its own source attached, name and URL. The page renders the source link right after the sentence it supports.</p>
<p>Here's how cooked shrimp reads now. FDA lists shrimp as a Best Choice. For pregnancy, FDA advises 2 to 3 servings of 4 ounces a week from the Best Choices list in total, not that amount of shrimp on top of other fish. FDA says the flesh of cooked shrimp should be opaque (milky white). The CDC line about raw or undercooked shellfish gets its own link.</p>
<p>That "in total" bit was a real bug in the old copy. It made shrimp sound like its own separate allowance.</p>
<p>Kale was the entry I used as the pattern for the rest. The nutrient figures cite PregNut's own data notes on the About page, and the copy says those numbers describe a food sample, "not a suggested serving or a guarantee about your own preparation." The CDC line says washed-then-cooked produce is safest, and then the entry says plainly that boiling doesn't guarantee every microbe is gone.</p>
<p>Writing it this way is slower. You can't hide a vague sentence when it has to point at a specific page that backs it up.</p>
<h2>A validator that rejects my worst habits</h2>
<p>There's a small script that checks every batch before it's compiled. It fails an entry if it's missing a verdict, explanation or sources. It wants roughly three or four sentences. It also has a list of banned patterns: "superfood," "powerhouse," "crucial role," "studies show," "experts recommend," em and en dashes, and the generic "Always consult your doctor."</p>
<p>I'm not against that advice. I banned the phrase because when it's pasted onto every page, nobody reads it. The site has one clear line in the footer and on the About page saying PregNut is not medical advice, and the food notes spend their sentences on specifics instead.</p>
<h2>Labeling what isn't official</h2>
<p>Some numbers on PregNut aren't official targets, and the site says so in the place you'd see them. There's no U.S. RDA for DHA, so 300 mg is labeled a comparison benchmark. The 70 g protein figure is labeled a benchmark too. Choline and potassium are adequate intakes, not RDAs.</p>
<p>The About page also says some guides were drafted with AI assistance and edited before publication, and that medical review is only credited when it actually happened. I'd rather be upfront about that than have someone assume a doctor signed off on a page they didn't.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/de820021-dec9-42e9-86cb-a7935ad4a32e.png" alt="Salmon on a phone, with the benchmark wording right where you'd look." style="display:block;margin:0 auto" />

<p><em>Salmon on a phone, with the benchmark wording right where you'd look.</em></p>
<h2>What I'd tell another builder</h2>
<p>If your site touches health, read your own copy the way a worried person would at 11pm. Search for absolute words. Make every claim point somewhere specific, and put an "Updated" date on the page so people can see how fresh it is. Give readers a way to report a wrong number; my contact form has "Correction or source issue" as its own topic.</p>
<p>PregNut is free at <a href="https://pregnut.com">pregnut.com</a>. It's an educational reference and doesn't replace your doctor or midwife. If you find a note that still overreaches, tell me and I'll fix it.</p>
]]></content:encoded></item><item><title><![CDATA[Sealing a YouTube Embed So Kids Can't Click Their Way Out]]></title><description><![CDATA[How I built WiseTube's player, picked what goes in the library, and made "no autoplay" mean something.]]></description><link>https://ystasyuk.hashnode.dev/youtube-autoplay-kept-feeding-my-kids-brainrot-so-i-built-wisetube</link><guid isPermaLink="true">https://ystasyuk.hashnode.dev/youtube-autoplay-kept-feeding-my-kids-brainrot-so-i-built-wisetube</guid><category><![CDATA[parenting]]></category><category><![CDATA[youtube]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Yuriy Stasyuk]]></dc:creator><pubDate>Mon, 26 Oct 2026 00:57:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/e356fa37-dd04-4036-8f8d-421cf496b7e0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A YouTube embed looks harmless. You drop an iframe on a page and a video plays. Then the video ends and a grid of suggestions shows up. Or your kid pauses it and gets a wall of thumbnails. Or they tap the little title at the top and they're on <a href="http://youtube.com">youtube.com</a> with the sidebar wide open.</p>
<p>I wanted my kids to get the good teaching channels without any of those doors. So the first real problem in building WiseTube was boring and specific. How do you take an embed that YouTube controls and make every exit go nowhere?</p>
<p>This is how I ended up doing it, plus the stuff I'd tell anyone trying the same thing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/c8616612-a68f-496d-93fa-5e1b272e3c06.png" alt="The front door. Everything behind it plays inside WiseTube." style="display:block;margin:0 auto" />

<p><em>The front door. Everything behind it plays inside WiseTube.</em></p>
<h2>Start with the player settings</h2>
<p>WiseTube loads YouTube's IFrame Player API and points it at <a href="http://youtube-nocookie.com">youtube-nocookie.com</a>. The player vars do a lot of the first pass: <code>controls: 0</code> (no native controls, so no logo or "Watch on YouTube" link), <code>disablekb: 1</code>, <code>fs: 0</code>, <code>iv_load_policy: 3</code> for annotations, and <code>rel: 0</code>.</p>
<p>I expected <code>rel: 0</code> to turn off suggestions. It doesn't. These days it only limits the suggestions to the same channel. They still appear. So settings alone were never going to be enough.</p>
<h2>Three layers on top of the iframe</h2>
<p><strong>The crop.</strong> The iframe is 240px taller than the visible frame, 120px hanging off the top and 120px off the bottom, inside a box with <code>overflow: hidden</code>. The video letterboxes into the middle and whatever chrome YouTube still draws at the edges lands outside the window.</p>
<p><strong>The shield.</strong> A transparent div sits over the whole iframe. The iframe itself gets <code>pointer-events: none</code>, <code>tabindex="-1"</code> and <code>aria-hidden</code>. Clicking the video hits the shield, which toggles play and pause. Right-click is blocked on the player. Nothing inside YouTube's frame can be clicked or focused, so there's no link to follow.</p>
<p><strong>The cover.</strong> Whenever the film isn't playing or buffering, an opaque WiseTube panel covers the player. It shows a dimmed thumbnail and a big play button. That's what hides YouTube's pause-screen suggestions. And the end screen never gets a chance to render, because when the player reports ENDED, the page swaps the player out for its own end panel.</p>
<h2>What "no autoplay" means in code</h2>
<p>When a film finishes, WiseTube shows a calm end panel with three picks from the same library, plus replay. Nothing counts down. Nothing starts until a kid taps something.</p>
<p>One honest wrinkle here. Inside the player, <code>autoplay</code> is set to 1. That's for the film the kid already chose, so it starts when they open it. If the browser blocks that, the code checks the player state after about a second and shows WiseTube's own play button. Autoplay of the <em>next</em> thing is what I removed, and that's the one that was walking my kids into weird channels.</p>
<h2>Building your own controls</h2>
<p>Turn off YouTube's controls and you own all of them. Play, pause, seek, volume, mute, speed, fullscreen, the keyboard shortcuts. I rebuilt the familiar keys (space and k, j and l, the arrows, f, m) on WiseTube's own container. Speed and volume get saved to localStorage so they stick between films.</p>
<p>Fullscreen was annoying. Some browsers won't let you fullscreen a plain div, so if <code>requestFullscreen</code> is missing or fails, the player switches to a CSS "fake fullscreen" that fills the window instead.</p>
<h2>Curation is a list, on purpose</h2>
<p>The library is driven by an explicit allowlist of channels kept in a JSON file, with age groups set per channel. The importer reads a channel's public Videos tab. Shorts and live streams live on separate YouTube tabs, so they never come in. The catalog tests fail the build if an import lets in a Short, a duplicate, an unknown topic, or a channel I ruled out.</p>
<p>I tried some automated screening along the way. It was useful for flagging, but I don't trust it to make the final call for kids, so the allowlist stays manual.</p>
<img src="https://cdn.hashnode.com/uploads/covers/570e6b923aeb5317437383ff/29285568-f8c2-4d92-8841-f9f61af89913.png" alt="A topic shelf. Every channel on it got there by hand." style="display:block;margin:0 auto" />

<p><em>A topic shelf. Every channel on it got there by hand.</em></p>
<h2>Tradeoffs I live with</h2>
<p>Some creators turn off playing outside YouTube. Those films throw error 101 or 150. WiseTube says "This film won't play here" in kid language and hides that film on the device. Error 100 means it was taken down. Other errors get a retry.</p>
<p>There's no backend. Profiles, progress, the parent PIN and settings all live in the browser on that device. That means no accounts and no email. It also means switching tablets starts fresh, which is why there's export and import in the parent settings.</p>
<p>WiseTube runs no ads of its own, but a creator's video can still carry a YouTube ad. I can't seal that part. The site also uses Google Analytics to count visits.</p>
<p>The whole thing is a static React + Vite build on Cloudflare Pages. A <code>_redirects</code> file sends app routes like <code>/watch/...</code> to the app shell so refresh works.</p>
<p>If you want to poke at the player, it's at <a href="https://wisetube.yuriystasyuk.com">wisetube.yuriystasyuk.com</a>. Pause a film and see what you get. If you find a way out, I'd like to know.</p>
]]></content:encoded></item></channel></rss>