<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sunah kim</title>
    <description>The latest articles on DEV Community by sunah kim (@pythona).</description>
    <link>https://dev.to/pythona</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4170698%2F17c9c746-a38d-4e91-a5ca-2a65d7821a71.png</url>
      <title>DEV Community: sunah kim</title>
      <link>https://dev.to/pythona</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pythona"/>
    <language>en</language>
    <item>
      <title>Twig 3.30 Broke Every Page of EC-CUBE with a 500 — Blame the Return Types Added in 3.12</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:58:19 +0000</pubDate>
      <link>https://dev.to/pythona/twig-330-broke-every-page-of-ec-cube-with-a-500-blame-the-return-types-added-in-312-g99</link>
      <guid>https://dev.to/pythona/twig-330-broke-every-page-of-ec-cube-with-a-500-blame-the-return-types-added-in-312-g99</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;While running &lt;code&gt;composer audit&lt;/code&gt; on a production EC-CUBE 4.3 site, I found a security advisory against &lt;code&gt;twig/twig&lt;/code&gt;. "Just bump it to the patched version" — so I updated Twig from the 3.8 line to 3.30 and deployed to the dev environment. &lt;strong&gt;Every single page returned 500.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why does a minor update within the same 3.x line break things this badly? The cause was the return types Twig added to its abstract methods in 3.12.0. This post is a record of the investigation, the 6-line fix, and the verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened — 500s everywhere and a Fatal error
&lt;/h2&gt;

&lt;p&gt;Right after the deploy, every URL returned 500. The PHP error log showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error:  Declaration of Eccube\Twig\Template::getTemplateName()
must be compatible with Twig\Template::getTemplateName(): string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EC-CUBE 4.3 ships its own &lt;code&gt;Eccube\Twig\Template&lt;/code&gt; class that extends &lt;code&gt;\Twig\Template&lt;/code&gt;, and PHP was complaining that the override is no longer "compatible" with the parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause — return types on abstract methods in Twig 3.12.0
&lt;/h2&gt;

&lt;p&gt;In 3.12.0, Twig added return types to five abstract methods of its &lt;code&gt;Template&lt;/code&gt; class:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Declaration since 3.12.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getTemplateName()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;: string&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getDebugInfo()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;: array&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getSourceContext()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;: Source&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;doDisplay()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;: iterable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;display()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;: void&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In PHP, once a parent method declares a return type, every override must declare a compatible (covariant) return type. &lt;strong&gt;Having no return type does not count as compatible.&lt;/strong&gt; The moment the parent gains a type, every untyped subclass turns into a Fatal error.&lt;/p&gt;

&lt;p&gt;To pinpoint which version introduced the change, I compared &lt;code&gt;src/Template.php&lt;/code&gt; across tags using GitHub raw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;v &lt;span class="k"&gt;in &lt;/span&gt;v3.9.0 v3.11.0 v3.12.0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://raw.githubusercontent.com/twigphp/Twig/&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;/src/Template.php"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'abstract .*function|function (getTemplateName|getDebugInfo|getSourceContext|doDisplay|display)\('&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No types up to v3.11.0; types from v3.12.0. That's the boundary.&lt;/p&gt;

&lt;p&gt;The tricky part is on the EC-CUBE side: neither 4.3.0 nor 4.3.1 has return types on this class. The &lt;code&gt;composer.json&lt;/code&gt; declares &lt;code&gt;twig/twig: ^3.8&lt;/code&gt;, yet &lt;strong&gt;in practice it only runs on 3.11 or below&lt;/strong&gt;. The fix only landed in 4.4.0-alpha-1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — add return types to 5 overrides (6 lines)
&lt;/h2&gt;

&lt;p&gt;Flip that around and the "answer" already exists in the corresponding file of 4.4.0-alpha-1. I backported it to 4.3 by adding return types to the five overrides in &lt;code&gt;Eccube\Twig\Template&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    public function getTemplateName()
&lt;/span&gt;&lt;span class="gi"&gt;+    public function getTemplateName(): string
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-    public function getDebugInfo()
&lt;/span&gt;&lt;span class="gi"&gt;+    public function getDebugInfo(): array
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-    public function getSourceContext()
&lt;/span&gt;&lt;span class="gi"&gt;+    public function getSourceContext(): \Twig\Source
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-    protected function doDisplay(array $context, array $blocks = [])
&lt;/span&gt;&lt;span class="gi"&gt;+    protected function doDisplay(array $context, array $blocks = []): array
&lt;/span&gt;     {
&lt;span class="gi"&gt;+        return [];
&lt;/span&gt;     }
&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-    public function display(array $context, array $blocks = [])
&lt;/span&gt;&lt;span class="gi"&gt;+    public function display(array $context, array $blocks = []): void
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two points worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parent declares &lt;code&gt;doDisplay(): iterable&lt;/code&gt;, so I declared the override as &lt;code&gt;: array&lt;/code&gt; — a covariant subtype — and added &lt;code&gt;return [];&lt;/code&gt; (the method never returned anything before).&lt;/li&gt;
&lt;li&gt;I wanted to add the &lt;code&gt;#[\Override]&lt;/code&gt; attribute too, but that's a PHP 8.3 feature and the server runs PHP 8.2, so I skipped it this time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification and rollback plan
&lt;/h2&gt;

&lt;p&gt;The fix is 6 lines, but it's a production site, so verification went step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Syntax check with &lt;code&gt;php -l&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Deploy to dev, then &lt;code&gt;bin/console cache:clear&lt;/code&gt; → &lt;code&gt;cache:warmup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;curl the 8 main pages — all 200&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bin/console lint:twig&lt;/code&gt; — all 258 templates OK&lt;/li&gt;
&lt;li&gt;Confirm 0 ERROR lines in the site log&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;composer audit&lt;/code&gt; now reports 0 advisories&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also prepared the rollback path up front: keep a full backup of the pre-update &lt;code&gt;vendor/&lt;/code&gt; directory, and restoring is just a &lt;code&gt;mv&lt;/code&gt; swap — about 40 seconds measured. Knowing you can go back makes applying changes to production far less stressful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Changing an abstract method's signature is a BC break in semver terms, and Twig did exactly that in 3.12. "Same 3.x, so it's safe" &lt;strong&gt;stops being true the moment you inherit from a framework class.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Keep a list of which upstream abstract methods your codebase overrides. Those are the candidate breakage points for the next update.&lt;/li&gt;
&lt;li&gt;Look at the corresponding file in the upstream's next major release (even an alpha) — the fix you need to backport is often already written there. In this case, 4.4.0-alpha-1 was the answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real takeaway for me: I now have one concrete answer to "I only bumped within 3.x — why did it break?"&lt;/p&gt;

</description>
      <category>php</category>
      <category>twig</category>
      <category>eccube</category>
      <category>composer</category>
    </item>
    <item>
      <title>The 3 Principles of Harness Engineering — Map, Enforce, Accumulate</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:59:56 +0000</pubDate>
      <link>https://dev.to/pythona/the-3-principles-of-harness-engineering-map-enforce-accumulate-311h</link>
      <guid>https://dev.to/pythona/the-3-principles-of-harness-engineering-map-enforce-accumulate-311h</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Recently I wrote about setting up a harness (CLAUDE.md / Hooks / Skills) for Claude Code in my side-project app:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zenn.dev/pythona/articles/claude-code-harness-first-setup" rel="noopener noreferrer"&gt;https://zenn.dev/pythona/articles/claude-code-harness-first-setup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That post was a hands-on record of &lt;em&gt;what&lt;/em&gt; I built. This time I want to step back and organize the underlying idea — the &lt;strong&gt;three principles of harness engineering&lt;/strong&gt; — as a mapping table: which Claude Code feature is responsible for which principle. The principles themselves come from an article by OpenAI; I learned about them through a Japanese summary page (linked under "References" at the end).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: I was only able to read the first part of the reference page (the principles table, the prompt structure, and the beginning of the CLAUDE.md guide). My interpretation here is partly filled in from my own practice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mapping the 3 principles to Claude Code features
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Principle&lt;/th&gt;
&lt;th&gt;In one line&lt;/th&gt;
&lt;th&gt;Claude Code feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① Hand over a map, not a manual&lt;/td&gt;
&lt;td&gt;Teach the big picture and &lt;em&gt;where to look&lt;/em&gt;, nothing more&lt;/td&gt;
&lt;td&gt;CLAUDE.md (+ docs/)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Enforce invariants, don't dictate implementation&lt;/td&gt;
&lt;td&gt;Mechanically guard only the rules whose violation is an incident&lt;/td&gt;
&lt;td&gt;Hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Codify missing capabilities&lt;/td&gt;
&lt;td&gt;Turn every failed procedure into an asset&lt;/td&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reduced to these three lines, it becomes clear that each plays a completely different role. Let's go through them.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① The map — CLAUDE.md as a signpost under 200 lines
&lt;/h3&gt;

&lt;p&gt;The temptation is to hand over the whole manual. But a long manual eats context, and the most important lines are exactly the ones that get skipped. The map principle says: keep CLAUDE.md a &lt;strong&gt;signpost under 200 lines&lt;/strong&gt;, push the details out into &lt;code&gt;docs/&lt;/code&gt;, and let the agent follow links when it needs them.&lt;/p&gt;

&lt;p&gt;The recommended structure is five sections, with the project overview first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# CLAUDE.md (skeleton)&lt;/span&gt;
&lt;span class="gu"&gt;## 1. Project overview   &amp;lt;- always goes first&lt;/span&gt;
&lt;span class="gu"&gt;## 2. Tech stack &amp;amp; common commands&lt;/span&gt;
&lt;span class="gu"&gt;## 3. Invariants (violations get blocked by Hooks)&lt;/span&gt;
&lt;span class="gu"&gt;## 4. Recommended conventions (recoverable if broken)&lt;/span&gt;
&lt;span class="gu"&gt;## 5. Where to Look (details live in docs/)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my own project, following this policy got CLAUDE.md down to 48 lines. The default move becomes "evict to docs/" rather than "add another line".&lt;/p&gt;

&lt;h3&gt;
  
  
  ② Enforcement — don't mix "please" with invariants
&lt;/h3&gt;

&lt;p&gt;Natural-language instructions are, at bottom, all "please" — they are followed probabilistically. Meanwhile, accidents like reading &lt;code&gt;.env&lt;/code&gt; or directly editing native directories are unacceptable even once.&lt;/p&gt;

&lt;p&gt;So this principle says: mechanically block &lt;strong&gt;only the things whose violation is an incident&lt;/strong&gt;, using a PreToolUse Hook (exit 2), and leave everything else as "recommended" in CLAUDE.md. The other direction matters just as much: &lt;strong&gt;don't use Hooks and rules to pin down &lt;em&gt;how&lt;/em&gt; to implement things&lt;/strong&gt;. Over-constrain the means and you block off the model's own toolbox of solutions. Guard the conditions; delegate the implementation.&lt;/p&gt;

&lt;p&gt;The concrete Hook setup and how I tested it are in my previous post, so I'll defer to that.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ Accumulation — Skills grow one per failure
&lt;/h3&gt;

&lt;p&gt;The third principle is the least flashy and the most effective. Every time something fails, write the correct procedure down as a Skill. From then on, you just invoke it and never trip over the same step again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;new-screen&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Steps for adding a new screen&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Create the expo-router route file under app/
&lt;span class="p"&gt;2.&lt;/span&gt; Put the screen component under components/screens/
&lt;span class="p"&gt;3.&lt;/span&gt; Run type checks and tests after adding it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each one is a small memo, but the longer the project runs, the more "handrails appear exactly where you fell", so the effect compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow in practice: build the harness before the app
&lt;/h2&gt;

&lt;p&gt;Once the mapping table is in your head, the opening move of a new project changes. My first prompt now generates the harness structure, not the app itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set up a Claude Code harness for this repository.
1. CLAUDE.md (under 200 lines, 5 sections, project overview first)
2. PreToolUse hooks under .claude/hooks/ that guard invariants (e.g. protect .env)
3. Skill skeletons for /commit and /review under .claude/skills/
Split detailed documentation into docs/.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When moving on to app implementation, I state the design and verification conditions up front: e.g. "monotone color scheme", "no emoji as decoration", "after implementing, always run the lint → type check → test verification loop". Handing these over as initial conditions is far faster than going back and forth later with "this looks AI-generated, please fix it". Assets and layout tweaks, on the other hand, I treat as something that won't land in one shot — those get refined through iterative instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Map (CLAUDE.md)&lt;/strong&gt;: a signpost under 200 lines. Evict details to docs/ and let the agent follow links&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce (Hooks)&lt;/strong&gt;: mechanically block only the invariants whose violation is an incident. Don't constrain how to implement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accumulate (Skills)&lt;/strong&gt;: document the procedure every time something fails. The effect compounds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thinking in these three layers — map, enforce, accumulate — you get a development environment that stops repeating the same mistakes through &lt;em&gt;structure&lt;/em&gt;, not through prompt-writing skill. Before polishing the prompt, suspect the harness. That's the order I think in these days.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://likeable-adasaurus-20e.notion.site/26-06-15-Claude-Code-381ce98ca898809b8fc2c65b623c3b37" rel="noopener noreferrer"&gt;Japanese summary page on Claude Code and harness engineering (Notion)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>llm</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Setting Up a Harness for Claude Code: Enforce Invariants with Hooks, Not Prompts</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:55:06 +0000</pubDate>
      <link>https://dev.to/pythona/setting-up-a-harness-for-claude-code-enforce-invariants-with-hooks-not-prompts-24nj</link>
      <guid>https://dev.to/pythona/setting-up-a-harness-for-claude-code-enforce-invariants-with-hooks-not-prompts-24nj</guid>
      <description>&lt;h2&gt;
  
  
  Tired of writing the same warnings every time
&lt;/h2&gt;

&lt;p&gt;I've been using Claude Code on FitLog, a personal workout-tracking app built with Expo / Supabase / Zustand / NativeWind / React Native Reusables. After a while I noticed the same mistakes kept happening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It would casually read &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It tried to edit &lt;code&gt;ios/&lt;/code&gt; and &lt;code&gt;android/&lt;/code&gt; directly, even though Expo manages them&lt;/li&gt;
&lt;li&gt;It modified the original &lt;code&gt;components/ui/&lt;/code&gt; files generated by Reusables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each time, I added another "please don't do X" to my prompts. The warnings kept piling up, but whether they were followed was basically luck. The longer the instructions got, the more the important parts got buried.&lt;/p&gt;

&lt;p&gt;Around that time, OpenAI published a piece on "harness engineering" that was making the rounds, so I took the opportunity to set up a proper harness for the project from scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To be honest: the original page returned a 403 and I couldn't read it. This article is not a commentary on the original — it's a record of applying three principles I picked up from summaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the agent a map, not a manual&lt;/li&gt;
&lt;li&gt;Enforce invariants; don't dictate implementation&lt;/li&gt;
&lt;li&gt;Disclose details progressively, when needed&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The cause: every rule was a "please"
&lt;/h2&gt;

&lt;p&gt;Looking back, the problem was how the rules were written. "Things that must never happen" and "things I'd prefer" sat side by side in my prompts and CLAUDE.md, written in exactly the same tone.&lt;/p&gt;

&lt;p&gt;Natural-language instructions to an LLM are all, fundamentally, requests. As the context grows, some get skipped. But an accident like reading &lt;code&gt;.env&lt;/code&gt; is game over even if it happens once. I had been placing rules that needed deterministic enforcement in a place where compliance was probabilistic.&lt;/p&gt;

&lt;p&gt;Following "enforce invariants, don't dictate implementation," I sorted the rules into two buckets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invariants (breaking them = incident)&lt;/td&gt;
&lt;td&gt;Accessing &lt;code&gt;.env*&lt;/code&gt;, hand-editing &lt;code&gt;ios/&lt;/code&gt;/&lt;code&gt;android/&lt;/code&gt;, modifying &lt;code&gt;components/ui/&lt;/code&gt; originals&lt;/td&gt;
&lt;td&gt;Mechanically blocked by hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommendations (breaking them = fixable)&lt;/td&gt;
&lt;td&gt;Naming conventions, directory layout, commit granularity&lt;/td&gt;
&lt;td&gt;Listed as "recommended" in CLAUDE.md&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The fix: a 48-line map + 4 hooks + 5 skills
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keep CLAUDE.md down to a 48-line "map"
&lt;/h3&gt;

&lt;p&gt;CLAUDE.md now contains only the stack, common commands, the list of invariants, and a "where to look" table — everything else is a link into &lt;code&gt;docs/&lt;/code&gt;. It ended up at 48 lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Where to Look&lt;/span&gt;
| What you need | Where |
|---|---|
| Screens &amp;amp; navigation | docs/architecture.md |
| DB schema &amp;amp; RLS | docs/db-schema.md |
| How to use UI components | docs/reusables-guide.md |
| Naming &amp;amp; coding conventions | docs/conventions.md |
| Hooks &amp;amp; skills, and why they exist | docs/harness.md |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docs/&lt;/code&gt; has six files: architecture / db-schema / reusables-guide / conventions / harness / design-docs. Instead of making the agent read everything every time, the structure lets it navigate there when needed — my attempt at progressive disclosure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforce invariants with PreToolUse hooks and exit 2
&lt;/h3&gt;

&lt;p&gt;I wrote four hooks: protect-env, protect-native, format (silently skips if Prettier isn't installed), and a Stop hook that summarizes &lt;code&gt;git diff --stat&lt;/code&gt; as a system message.&lt;/p&gt;

&lt;p&gt;The core one, protect-env, looks like this (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# .claude/hooks/protect-env.sh&lt;/span&gt;
&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // .tool_input.file_path // ""'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# .env.example is allowed&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'\.env\.example'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi

if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'(^|[ /"'&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;'])\.env(\.|[ "'&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;']|$)'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED: access to .env* is forbidden."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"For variable names, see .env.example instead."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point: don't just stop with exit 2 — &lt;strong&gt;use stderr to tell the agent what to do instead&lt;/strong&gt;. protect-native suggests "use an Expo Config Plugin instead of editing &lt;code&gt;ios/&lt;/code&gt; directly"; the Reusables guard says "wrap the component instead of changing the original." A blocked Claude picks its next action, so showing it a way forward gets it back on track faster.&lt;/p&gt;

&lt;p&gt;Patterns that leak through staging rather than reading — like &lt;code&gt;git add .env&lt;/code&gt; — are caught by the same hook.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash|Read|Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".claude/hooks/protect-env.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".claude/hooks/protect-native.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hook you haven't tested is a hook you can't trust, so I prepared ten sample JSON inputs (six that should block, four that should pass) and verified the exit codes directly in the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./test-hooks.sh
[BLOCK] cat .env                          -&amp;gt; exit 2 OK
[BLOCK] git add .env                      -&amp;gt; exit 2 OK
[ALLOW] cat .env.example                  -&amp;gt; exit 0 OK
[ALLOW] ls src/components                 -&amp;gt; exit 0 OK
...
10/10 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vary how skills are invoked by blast radius
&lt;/h3&gt;

&lt;p&gt;Five skills: &lt;code&gt;/commit&lt;/code&gt;, &lt;code&gt;/review&lt;/code&gt;, &lt;code&gt;/deploy&lt;/code&gt;, &lt;code&gt;/cleanup&lt;/code&gt;, &lt;code&gt;/new-screen&lt;/code&gt;. &lt;code&gt;/commit&lt;/code&gt; encodes the commit-message conventions (Korean, no trailing period, &lt;code&gt;type: description&lt;/code&gt; format, &lt;code&gt;git add .&lt;/code&gt; forbidden). &lt;code&gt;/deploy&lt;/code&gt; encodes "EAS preview build only after tests and tsc pass."&lt;/p&gt;

&lt;p&gt;Anything with a large blast radius, like deploys, gets &lt;code&gt;disable-model-invocation: true&lt;/code&gt; so the model can't call it autonomously — it's user-invocation only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong, and what's still unverified
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Couldn't read the original.&lt;/strong&gt; The 403 meant I designed from summaries. Not pretending to know what you haven't read applies to blog posts and to AI work alike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude guess-interpreted a broken README.&lt;/strong&gt; During setup, it filled in a README full of typos and truncated sentences with "it probably means this" and kept going. If the map itself is broken, a harness won't prevent misreading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks untested in a real session.&lt;/strong&gt; The standalone tests passed 10/10, but I haven't yet confirmed they fire as intended mid-session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep CLAUDE.md a short map; push details into &lt;code&gt;docs/&lt;/code&gt; and let the agent follow links&lt;/li&gt;
&lt;li&gt;Split rules into "enforced" vs. "delegated." Only incident-grade rules get a hook with exit 2; everything else stays a recommendation&lt;/li&gt;
&lt;li&gt;When you block, use stderr to point at the alternative — recovery is faster&lt;/li&gt;
&lt;li&gt;Test hooks against sample inputs and check exit codes before relying on them&lt;/li&gt;
&lt;li&gt;Don't start with hooks for everything; promote a rule to a hook only after the same mistake repeats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than stacking up more "pleases," guard the few places where a breach would actually hurt — mechanically. That trade-off alone made my prompts shorter and a lot quieter.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>llm</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Open Any Finder Folder in VS Code with One Keystroke (Automator Quick Action)</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:56:25 +0000</pubDate>
      <link>https://dev.to/pythona/open-any-finder-folder-in-vs-code-with-one-keystroke-automator-quick-action-29ii</link>
      <guid>https://dev.to/pythona/open-any-finder-folder-in-vs-code-with-one-keystroke-automator-quick-action-29ii</guid>
      <description>&lt;h2&gt;
  
  
  Dragging folders gets old fast
&lt;/h2&gt;

&lt;p&gt;When you're browsing a project folder in Finder and think "I want this open in VS Code" — what do you do?&lt;/p&gt;

&lt;p&gt;For a long time, I would launch VS Code first and then drag the folder onto the Dock icon or the window. Or open a terminal, &lt;code&gt;cd&lt;/code&gt; into the folder, and run &lt;code&gt;code .&lt;/code&gt;. Either way it's only a few seconds, but repeated many times a day it adds up. It feels especially tedious when you just want to peek at the contents.&lt;/p&gt;

&lt;p&gt;With macOS's built-in Automator, you can create a "Quick Action" once, and from then on it's: select the folder in Finder → right-click (or press a shortcut key) → VS Code opens instantly. Setup takes five minutes. Here's the procedure, plus the one detail that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Quick Action in Automator
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create a new Quick Action
&lt;/h3&gt;

&lt;p&gt;Launch Automator.app and choose "Quick Action" as the new document type.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Configure how input is received
&lt;/h3&gt;

&lt;p&gt;Change the settings at the top of the workflow as follows. If you get this wrong, the action won't appear in Finder's right-click menu at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Workflow receives current" → &lt;strong&gt;folders&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"in" → &lt;strong&gt;Finder.app&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Add "Run Shell Script"
&lt;/h3&gt;

&lt;p&gt;Drag "Run Shell Script" from the action list on the left into the workflow, and configure it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shell: &lt;code&gt;/bin/zsh&lt;/code&gt; (bash works too)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass input: "as arguments"&lt;/strong&gt; (if you leave the default "to stdin", &lt;code&gt;"$@"&lt;/code&gt; will be empty)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script itself is just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Visual Studio Code"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop is there so that selecting multiple folders opens each one in its own window.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Save
&lt;/h3&gt;

&lt;p&gt;Press &lt;code&gt;⌘S&lt;/code&gt; and give it a name. I went with "Open in VS Code" — this name is exactly what shows up in the context menu.&lt;/p&gt;

&lt;p&gt;Now you can right-click any folder in Finder → "Quick Actions" → "Open in VS Code".&lt;/p&gt;

&lt;h2&gt;
  
  
  Assigning a keyboard shortcut
&lt;/h2&gt;

&lt;p&gt;If you want to skip even the right-click:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;System Settings → Keyboard → Keyboard Shortcuts&lt;/li&gt;
&lt;li&gt;Open "Services" → "Files and Folders" on the left&lt;/li&gt;
&lt;li&gt;Find your saved "Open in VS Code" action and assign a shortcut (I used &lt;code&gt;⌃⌥V&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Select a folder in Finder, press the key, and VS Code opens. Once you have this, there's no going back to dragging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key point: use &lt;code&gt;open -a&lt;/code&gt;, not the &lt;code&gt;code&lt;/code&gt; command
&lt;/h2&gt;

&lt;p&gt;It's tempting to write the script as &lt;code&gt;code "$f"&lt;/code&gt;, but using &lt;code&gt;open -a&lt;/code&gt; is the whole point of this setup. Two reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You sidestep the PATH problem.&lt;/strong&gt; The &lt;code&gt;code&lt;/code&gt; command only exists after you run "Shell Command: Install 'code' command in PATH" from the VS Code command palette. Worse, Automator's shell runs without loading &lt;code&gt;.zshrc&lt;/code&gt;, so your interactive shell's PATH settings don't apply. In other words, even if &lt;code&gt;code&lt;/code&gt; works fine in your terminal, inside Automator it tends to fail with &lt;code&gt;command not found&lt;/code&gt;. Meanwhile, &lt;code&gt;open -a&lt;/code&gt; is a standard macOS command that works from anywhere as long as the application name is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It's portable with zero setup.&lt;/strong&gt; With &lt;code&gt;open -a&lt;/code&gt;, the same Quick Action works on your teammates' Macs as long as VS Code is installed. No "please enable the &lt;code&gt;code&lt;/code&gt; command first" prerequisite — a small but real win.&lt;/p&gt;

&lt;p&gt;Note that the app name must match the official name in your Applications folder. If you use VS Code Insiders, it's &lt;code&gt;"Visual Studio Code - Insiders"&lt;/code&gt;. A mismatched name fails with an &lt;code&gt;Unable to find application&lt;/code&gt; error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Automator's Quick Actions let you go from a Finder right-click straight to VS Code&lt;/li&gt;
&lt;li&gt;Input must be "folders" / "Finder.app", passed "as arguments" — get these wrong and nothing works&lt;/li&gt;
&lt;li&gt;The script is &lt;code&gt;open -a "Visual Studio Code" "$f"&lt;/code&gt;; unlike the &lt;code&gt;code&lt;/code&gt; command, it needs no PATH setup&lt;/li&gt;
&lt;li&gt;Assign a key in System Settings → Keyboard Shortcuts → Services to shrink it down to a single keystroke&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the kind of setup you build once and use until you switch operating systems. If you're still dragging folders every time, it's worth the five-minute investment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://egg-developer.tistory.com/63" rel="noopener noreferrer"&gt;How to open a Finder folder directly in VS Code on Mac (Korean)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>vscode</category>
      <category>automator</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Clipped Dropdown in a Collapsed Sidebar — the Hidden Meaning of overflow-y: auto</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Wed, 21 Oct 2026 11:55:28 +0000</pubDate>
      <link>https://dev.to/pythona/the-clipped-dropdown-in-a-collapsed-sidebar-the-hidden-meaning-of-overflow-y-auto-4257</link>
      <guid>https://dev.to/pythona/the-clipped-dropdown-in-a-collapsed-sidebar-the-hidden-meaning-of-overflow-y-auto-4257</guid>
      <description>&lt;h2&gt;
  
  
  The QA report
&lt;/h2&gt;

&lt;p&gt;"When the sidebar is collapsed, the '+ Create' menu gets cut off."&lt;/p&gt;

&lt;p&gt;The screenshot showed exactly that: with the sidebar collapsed to 60px, the dropdown menu was sliced clean off at the sidebar's right border. Only the first character of "New folder" was visible.&lt;/p&gt;

&lt;p&gt;The strange part: with the sidebar expanded (244px), the same menu rendered fine. Same code, same 190px menu width. It only broke when collapsed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;p&gt;My first suspect was &lt;code&gt;z-index&lt;/code&gt; — maybe the menu was hiding under something. But DevTools showed the menu was in the DOM with a plenty-high &lt;code&gt;z-index&lt;/code&gt;. It wasn't hidden; it was being &lt;strong&gt;physically clipped at the sidebar's boundary&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Next suspect: &lt;code&gt;overflow&lt;/code&gt;. But all the sidebar had was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.sidebar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* collapsed; 244px when expanded */&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* vertical scroll for long item lists */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;overflow-x&lt;/code&gt; anywhere. "The horizontal axis is still &lt;code&gt;visible&lt;/code&gt;, so anything sticking out should show" — or so I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause: one non-visible axis drags the other along
&lt;/h2&gt;

&lt;p&gt;The CSS spec has this rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When one of &lt;code&gt;overflow-x&lt;/code&gt; / &lt;code&gt;overflow-y&lt;/code&gt; is set to something other than &lt;code&gt;visible&lt;/code&gt;, a &lt;code&gt;visible&lt;/code&gt; value on the other axis computes to &lt;code&gt;auto&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the moment you write &lt;code&gt;overflow-y: auto&lt;/code&gt;, the unspecified &lt;code&gt;overflow-x&lt;/code&gt; silently &lt;strong&gt;becomes &lt;code&gt;auto&lt;/code&gt;&lt;/strong&gt;. Sure enough, the Computed tab in DevTools showed &lt;code&gt;overflow-x: auto&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now everything added up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The menu was floated inside the sidebar with &lt;code&gt;position: absolute&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The sidebar was effectively an &lt;code&gt;overflow: auto auto&lt;/code&gt; scroll container&lt;/li&gt;
&lt;li&gt;A 190px menu inside a 60px container → the horizontal overflow gets clipped (or scrolled)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It had survived in the expanded state only because 244px happened to fit most of the menu, so the clipping wasn't noticeable. Structurally, it was doomed to clip from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;position: fixed&lt;/code&gt; only while collapsed
&lt;/h2&gt;

&lt;p&gt;I couldn't drop &lt;code&gt;overflow-y: auto&lt;/code&gt; — the scroll is needed. So instead, &lt;strong&gt;while collapsed, the menu escapes the scroll container's coordinate system&lt;/strong&gt;. &lt;code&gt;position: fixed&lt;/code&gt; positions against the viewport, so ancestor &lt;code&gt;overflow&lt;/code&gt; can't clip it (caveat: an ancestor with &lt;code&gt;transform&lt;/code&gt; etc. changes the containing block).&lt;/p&gt;

&lt;p&gt;The position comes from the button's &lt;code&gt;getBoundingClientRect()&lt;/code&gt;. In React, I store the coordinates in state on click and feed them into &lt;code&gt;style&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;menuPos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMenuPos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MouseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// float it just to the right of the button&lt;/span&gt;
    &lt;span class="nf"&gt;setMenuPos&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;collapsed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;menu menu--fixed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;menu menu--absolute&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;collapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;menuPos&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;menuPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;menuPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.menu--absolute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* expanded: as before */&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.menu--fixed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* collapsed: escape the sidebar */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept the expanded state on the old &lt;code&gt;absolute&lt;/code&gt; path. Moving everything to &lt;code&gt;fixed&lt;/code&gt; was tempting, but the expanded state has existing scroll-following behavior tangled in, so I limited the blast radius to the collapsed state.&lt;/p&gt;

&lt;p&gt;To verify, I opened the menu in the collapsed state on a local build and compared rectangles in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;menu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.menu--fixed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sidebar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.sidebar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;sidebar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true → the menu is outside the sidebar&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// still within the viewport?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;menu.left&lt;/code&gt; sat to the right of the sidebar's edge and the whole menu fit in the viewport. Done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Two lessons from this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A popover inside a scroll container will eventually get clipped.&lt;/strong&gt;&lt;br&gt;
The instant you write &lt;code&gt;overflow-y: auto&lt;/code&gt;, that element becomes a container that clips horizontally too. Any dropdown or tooltip floated inside it with &lt;code&gt;absolute&lt;/code&gt; gets cut the moment it outgrows the container. "It looks fine now" just means it happens to fit today. Put popovers on &lt;code&gt;fixed&lt;/code&gt; or in a Portal — outside the scroll container — from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Auxiliary states like "collapsed" deserve one pass per feature.&lt;/strong&gt;&lt;br&gt;
That same day we got three bug reports tied to the collapsed state: a drawer opening as a 60px sliver, the hamburger icon showing at desktop width, and this clipped menu. All of them worked perfectly when expanded and broke the moment you collapsed. We had only ever tested the default state. Of all the state combinations (expanded × collapsed, light × dark, desktop × mobile), the side you don't personally use is exactly the one worth a full lap.&lt;/p&gt;

&lt;p&gt;Hope this saves someone the same detour.&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>react</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Permissions Hadn't Changed, but QA Said 'I Can't Create' — Answering with a Table Uncovered an Unreachable Branch</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Sun, 18 Oct 2026 11:53:29 +0000</pubDate>
      <link>https://dev.to/pythona/permissions-hadnt-changed-but-qa-said-i-cant-create-answering-with-a-table-uncovered-an-489o</link>
      <guid>https://dev.to/pythona/permissions-hadnt-changed-but-qa-said-i-cant-create-answering-with-a-table-uncovered-an-489o</guid>
      <description>&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;QA for our internal business system sent me this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I can't create a project. Did it change so that only executives can create them now?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Short answer: no, the permission rules hadn't changed at all. But the question turned out to be valuable. While building a permission table to answer it, I cross-checked the code — and found a branch that could &lt;strong&gt;never execute&lt;/strong&gt;. Dead code, alive in production. Here's how it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: we had just changed how roles are stored
&lt;/h2&gt;

&lt;p&gt;Shortly before this, we changed how job roles are modeled.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before&lt;/strong&gt;: one role per user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After&lt;/strong&gt;: one role per affiliation (seat) — to support concurrent posts and secondments, a user can hold multiple affiliations, each with its own role&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project-creation rules themselves were untouched:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Can create projects in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chairman / Secretary&lt;/td&gt;
&lt;td&gt;Entire group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;President / Executive&lt;/td&gt;
&lt;td&gt;Own company&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Department head / Section chief / Team leader&lt;/td&gt;
&lt;td&gt;Own org and below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team member&lt;/td&gt;
&lt;td&gt;Nowhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "did the system change?" question almost certainly came because of that recent refactor. When a system is being actively changed, every unrelated symptom starts looking like a side effect of "that change."&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying to answer with a table, I read the code
&lt;/h2&gt;

&lt;p&gt;I could have just replied "nothing changed," but for permission questions, &lt;strong&gt;a table is the most reliable answer&lt;/strong&gt;. To build the table above, I re-read the actual check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Scope&lt;/span&gt; &lt;span class="nf"&gt;scopeOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;userSeq&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;CHAIRMAN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;SECRETARY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;companyWide&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;PRESIDENT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;EXECUTIVE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ownCompany&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;TEAM_MEMBER&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ← early return&lt;/span&gt;

    &lt;span class="c1"&gt;// Per-affiliation branch (added in the refactor):&lt;/span&gt;
    &lt;span class="c1"&gt;// even if the primary affiliation is "team member",&lt;/span&gt;
    &lt;span class="c1"&gt;// a section chief seat elsewhere can create under that org&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Affiliation&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;affiliationsOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userSeq&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isLeaderOrAbove&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;orgScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;org&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;denied&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See it? The &lt;code&gt;for&lt;/code&gt; loop was added during the per-affiliation refactor, precisely to handle "primary affiliation is team member, but section chief somewhere else." But above it, &lt;strong&gt;the pre-refactor early return for &lt;code&gt;TEAM_MEMBER&lt;/code&gt; was never removed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Since the &lt;code&gt;role&lt;/code&gt; argument carries the primary affiliation's role, any user whose primary role is team member hits &lt;code&gt;return denied()&lt;/code&gt; on line 3 — and the loop below it &lt;strong&gt;never runs&lt;/strong&gt;. The code we wrote for concurrent-post users had never executed, not even once.&lt;/p&gt;

&lt;p&gt;The compiler can't warn you about this. Syntactically, both branches are reachable. Code written after a &lt;code&gt;return&lt;/code&gt; statement triggers an unreachable-code error, but a branch that's unreachable &lt;em&gt;because of how the conditions combine&lt;/em&gt; can only be found by a human reading the code — or by a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: drop TEAM_MEMBER from the early return
&lt;/h2&gt;

&lt;p&gt;The fix itself was small: stop short-circuiting on "team member," and read the affiliations before judging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Scope&lt;/span&gt; &lt;span class="nf"&gt;scopeOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;userSeq&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;CHAIRMAN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;SECRETARY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;companyWide&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;PRESIDENT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;EXECUTIVE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ownCompany&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// If any seat is leader-or-above: that org and below. Otherwise: denied.&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Affiliation&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;affiliationsOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userSeq&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isLeaderOrAbove&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;orgScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;org&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;denied&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified with a user whose primary affiliation is a team member but who holds a section-chief seat at another company:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the creation-scope API returns &lt;code&gt;allowed: true&lt;/code&gt; with the section-chief org&lt;/li&gt;
&lt;li&gt;a project-creation POST under that section returns &lt;code&gt;201&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;creation under the primary org still returns &lt;code&gt;403&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more thing: deployment timing. If the server restarts while QA is in the middle of testing exactly this permission area, they get confused by unrelated errors. I scheduled the release for 14:00, right after lunch break, so the restart landed between test sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answering QA
&lt;/h2&gt;

&lt;p&gt;The answer to the original question was simple: "The permission rules haven't changed — here's who can create where," with the table attached. And for the cases where creation still fails, I asked three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which account, and that account's affiliations and roles&lt;/li&gt;
&lt;li&gt;whether the create button is &lt;strong&gt;missing&lt;/strong&gt; or clicking it &lt;strong&gt;errors out&lt;/strong&gt; (and if so, the exact message)&lt;/li&gt;
&lt;li&gt;which organization was selected as the creation target&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A bare "it doesn't work" is unreproducible for permission bugs, so having these triage questions templated saves a lot of round trips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When you add a branch below an early return, check that the branch is actually reachable.&lt;/strong&gt; The compiler won't catch condition-level unreachability; only a human read or a test will. If we'd had a concurrent-post user in the test suite from day one, we'd have caught this at refactor time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer permission questions with a table.&lt;/strong&gt; Building the table forces you to re-read the implementation, and mismatches between the rules and the code — like this dead branch — surface along the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule permission-related deploys outside QA's testing hours.&lt;/strong&gt; A single restart at the wrong moment costs you trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive-sounding "did the system change?" ended up unearthing a latent bug. I'm grateful for the question.&lt;/p&gt;

</description>
      <category>java</category>
      <category>backend</category>
      <category>debugging</category>
      <category>codequality</category>
    </item>
    <item>
      <title>Stamping a Company Seal on Excel Invoices: No Private Storage, a Lying resize(), and Three Coordinate Systems</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Sat, 17 Oct 2026 11:54:23 +0000</pubDate>
      <link>https://dev.to/pythona/stamping-a-company-seal-on-excel-invoices-no-private-storage-a-lying-resize-and-three-30lf</link>
      <guid>https://dev.to/pythona/stamping-a-company-seal-on-excel-invoices-no-private-storage-a-lying-resize-and-three-30lf</guid>
      <description>&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;p&gt;In our internal business system, I built a feature that stamps a company seal (the square &lt;em&gt;kaku-in&lt;/em&gt; used on Japanese business documents) onto invoices, quotes, and purchase orders. The documents are Excel templates filled with data and converted to PDF via LibreOffice. The requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never distribute the seal image by itself — no public URL, no image-serving API. It may only be seen stamped on a document&lt;/li&gt;
&lt;li&gt;Per-document ON/OFF toggle&lt;/li&gt;
&lt;li&gt;Once a document is finalized, replacing the seal later must not change it — but copying the image per document is forbidden&lt;/li&gt;
&lt;li&gt;Position it relative to the issuer block, slightly overlapping the address; same physical size across all three templates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Just paste one image," I thought. I then got stuck in three different layers: infrastructure, the library, and the coordinate systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: there was no private storage
&lt;/h2&gt;

&lt;p&gt;My first idea was the existing S3 bucket that stores attachments and logos. Then I read its bucket policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::&amp;lt;bucket&amp;gt;/*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Principal: *&lt;/code&gt; with &lt;code&gt;s3:GetObject&lt;/code&gt; — anyone who knows the key can read the object. No matter how careful the application code is, putting the seal there breaks the "never distribute" requirement at the storage layer.&lt;/p&gt;

&lt;p&gt;The seal is a PNG of a few dozen KB, so I stored it in a &lt;strong&gt;PostgreSQL &lt;code&gt;bytea&lt;/code&gt; column&lt;/strong&gt; instead. If you never build a GET endpoint for the image, a public URL cannot exist by construction. The only consumer is the document renderer.&lt;/p&gt;

&lt;p&gt;The seemingly contradictory pair "no copies, yet finalized documents are immutable" was solved by an &lt;strong&gt;append-only design&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replacing the seal inserts a new row and repoints the company's "current seal" reference&lt;/li&gt;
&lt;li&gt;Deleting the seal keeps the row and only cuts the reference&lt;/li&gt;
&lt;li&gt;Finalizing a document snapshots the seal's row ID onto the document&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finalized documents keep pointing at the old row, so later changes never touch them. There is always exactly one copy of each image.&lt;/p&gt;

&lt;p&gt;As a bonus finding, finalized PDFs were also sitting in that same public bucket under guessable keys. That was out of scope for this feature, so I filed it as an infrastructure task (prefix-level deny in the bucket policy) and handed it over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: POI's &lt;code&gt;resize()&lt;/code&gt; trusts the font
&lt;/h2&gt;

&lt;p&gt;Next, placing the image. With Apache POI I anchored the picture at the right edge of column F in the issuer block, set the anchor's dx, and called &lt;code&gt;Picture.resize(scale)&lt;/code&gt;. The seal ended up &lt;strong&gt;in the middle of the cell&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause is inside &lt;code&gt;resize()&lt;/code&gt;: it re-derives the horizontal offset by estimating cell widths &lt;strong&gt;from font metrics&lt;/strong&gt;. The template uses MS PGothic, and the estimate came out at roughly 60% of the real column width — so my carefully computed dx was overwritten based on a wrong premise.&lt;/p&gt;

&lt;p&gt;The fix was to drop &lt;code&gt;resize()&lt;/code&gt; entirely and compute all four corners of the &lt;code&gt;twoCellAnchor&lt;/code&gt; (from/to col, row, dx, dy, in EMUs) myself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;XSSFClientAnchor&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;XSSFClientAnchor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fromDx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromDy&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toDx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toDy&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fromCol&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromRow&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toCol&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toRow&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAnchorType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClientAnchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AnchorType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MOVE_DONT_RESIZE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Picture&lt;/span&gt; &lt;span class="n"&gt;pic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drawing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createPicture&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pictureIdx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Row heights read back accurately from the template, so vertically I just accumulate rows until the required height is reached. I also set the shape's &lt;code&gt;xfrm&lt;/code&gt; off/ext to the same values. Convenience methods hide their assumptions — here, the font.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: there were three coordinate systems
&lt;/h2&gt;

&lt;p&gt;The PDF output was still off in position and size. It turned out three separate layers transform coordinates, each modified at a different stage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Cell width in "characters".&lt;/strong&gt; Before PDF conversion, our pipeline enlarges fonts by 6pt and shrinks cell widths (which Excel stores in character units) proportionally to preserve the layout — e.g. 45.66 chars → 29.5 chars. Computing pixels from the shrunken value gives you a too-narrow cell. I multiply the font ratio back in first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Print scale.&lt;/strong&gt; The templates print at 61%, so 66 px on the sheet is 10 mm on paper. A kaku-in is conventionally 18–21 mm, which back-calculates to a ~110 px square on the sheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Millimeters on paper.&lt;/strong&gt; The only layer anyone actually cares about. Luckily all three templates use the same 61% scale, so equal sheet pixels automatically satisfied the "same size everywhere" requirement.&lt;/p&gt;

&lt;p&gt;Characters on the sheet, pixels on the sheet, millimeters on paper: ignore any one of the three and it looks right on screen and wrong on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification: what ships on paper must be checked on paper
&lt;/h2&gt;

&lt;p&gt;Unit-test templates are empty sheets, so they can't tell you what the paper will look like. I rendered 12 documents (3 real templates × long/short address × ON/OFF) with a dummy seal PNG, converted them with local LibreOffice, and cropped the top of each PDF to a PNG for eyeballing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;libreoffice &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--convert-to&lt;/span&gt; pdf out/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx
pdftoppm &lt;span class="nt"&gt;-png&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 100 &lt;span class="nt"&gt;-y&lt;/span&gt; 0 &lt;span class="nt"&gt;-H&lt;/span&gt; 350 out/invoice.pdf check/invoice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three "not yet" verdicts (not overlapping the address yet, too big, etc.) came from those images. The harness was deleted, not committed; what stayed in the repo were six unit tests: ON puts an anchor at column F with dx &amp;gt; 0, OFF renders no picture, ON with no registered seal renders no picture, text positions are identical between ON and OFF, and a finalized document keeps its seal after the company removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three small ones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;H2 (PostgreSQL compatibility mode) in tests doesn't understand &lt;code&gt;blob&lt;/code&gt;: a &lt;code&gt;byte[]&lt;/code&gt; column with a 4 MB limit made Hibernate choose &lt;code&gt;blob&lt;/code&gt; and the schema failed; lowering the limit to 1 MB switched it to &lt;code&gt;varbinary&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;"The file won't select" → a WebP file against &lt;code&gt;accept="image/png,image/jpeg"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The browser file input's default "no file selected" text looked out of place, so the input is hidden behind register/replace/delete buttons&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Private" breaks at the infrastructure, not in your code.&lt;/strong&gt; Read the bucket policy before choosing a storage location. If the storage is public, your access control isn't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convenience methods hide assumptions.&lt;/strong&gt; POI's &lt;code&gt;resize()&lt;/code&gt; estimates cell widths from fonts. When placement is off, go back to the low-level API (twoCellAnchor + EMU) and your own math&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What ships on paper must be verified on (something close to) paper.&lt;/strong&gt; Of the three coordinate layers, users only ever see the last one — verify in that layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A "just paste one image" feature ended up spanning bucket policies to EMUs. Next time someone says "you just place it," I'll start by counting the coordinate systems.&lt;/p&gt;

</description>
      <category>java</category>
      <category>apachepoi</category>
      <category>aws</category>
      <category>excel</category>
    </item>
    <item>
      <title>The Unclickable Hamburger Button: Three Wrong Fixes Before I Found the CSS Bundle Order Bug</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Fri, 16 Oct 2026 11:54:23 +0000</pubDate>
      <link>https://dev.to/pythona/the-unclickable-hamburger-button-three-wrong-fixes-before-i-found-the-css-bundle-order-bug-39b9</link>
      <guid>https://dev.to/pythona/the-unclickable-hamburger-button-three-wrong-fixes-before-i-found-the-css-bundle-order-bug-39b9</guid>
      <description>&lt;h2&gt;
  
  
  The report
&lt;/h2&gt;

&lt;p&gt;QA filed this against our internal business web app (React + Vite):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The three-line icon next to the date can't be clicked&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One screenshot attached. No device, no screen width, no description of what actually happened on click. From that single line, I fixed the wrong thing three times in a row — and the real culprit turned out to be &lt;strong&gt;the order of rules inside the production CSS bundle&lt;/strong&gt;. Here's the whole detour, as it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: there are two hamburger buttons
&lt;/h2&gt;

&lt;p&gt;The sidebar behaves differently depending on viewport width:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desktop (&amp;gt;900px)&lt;/strong&gt;: the sidebar is always visible. A "≡" button in the sidebar header collapses it into a 60px icon rail (the state is persisted in localStorage)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow (≤900px)&lt;/strong&gt;: the sidebar becomes a drawer, and a "≡" appears in the app bar (next to the date) to open it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So there are two "≡" buttons, and the app-bar one is hidden on desktop via CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.sidebar-toggle-mobile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.sidebar-toggle-mobile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "three-line icon next to the date" in the report is the app-bar button. I immediately assumed the reporter was on a phone. That assumption was mistake number one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong fix #1: the collapsed state in localStorage
&lt;/h2&gt;

&lt;p&gt;At 375px locally, everything worked. But when I set "collapsed=true" in localStorage, I could reproduce a state where the drawer opened only as a 60px rail — which does look like "nothing happens."&lt;/p&gt;

&lt;p&gt;Convinced I'd found it, I shipped a fix that ignores the collapsed flag at narrow widths. The reporter's reply: "Same as before."&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong fix #2: the collapse button inside the drawer
&lt;/h2&gt;

&lt;p&gt;Next I noticed the drawer still contained the desktop collapse "≡", which either shrank the drawer to a rail or did nothing. I replaced it with an "×" (close) button and hid the app-bar "≡" while the drawer was open. Second deploy.&lt;/p&gt;

&lt;p&gt;The reporter answered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't understand why there's a ≡ here at all&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence was the decisive clue: they were seeing a "≡" &lt;strong&gt;outside drawer mode&lt;/strong&gt;. And then came the detail that cracked it open:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'm looking at this on a 16-inch MacBook&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Desktop width. The button that &lt;code&gt;display: none&lt;/code&gt; was supposed to hide was visible on a wide screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cause: rule order in the bundle
&lt;/h2&gt;

&lt;p&gt;I downloaded the deployed production CSS bundle and located the two rules by byte offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.sidebar-toggle-mobile&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="nt"&gt;byte&lt;/span&gt; &lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="err"&gt;975&lt;/span&gt;
&lt;span class="nc"&gt;.icon-btn&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;         &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="nt"&gt;byte&lt;/span&gt; &lt;span class="err"&gt;72&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="err"&gt;008&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.icon-btn&lt;/code&gt; is a shared icon-button class, and this "≡" has it too. Both selectors are a single class, so their specificity is identical — and on a tie, &lt;strong&gt;the later rule wins&lt;/strong&gt;. &lt;code&gt;display: inline-flex&lt;/code&gt; was overriding &lt;code&gt;display: none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why only in production? The &lt;code&gt;sidebar.css&lt;/code&gt; imported by the component ended up &lt;em&gt;earlier&lt;/em&gt; in the bundle than the entry point's &lt;code&gt;index.css&lt;/code&gt;. An order that was harmless on the dev server flipped in the production build.&lt;/p&gt;

&lt;p&gt;Suddenly every observation lined up. On desktop, the "≡" is visible. Clicking it does update state — but the drawer and the backdrop are both disabled by CSS at desktop width, so nothing on screen changes. &lt;strong&gt;"It can't be clicked" was a perfectly accurate observation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a selector that doesn't depend on bundle order
&lt;/h2&gt;

&lt;p&gt;The fix is one line — add a parent class to raise specificity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.app-bar&lt;/span&gt; &lt;span class="nc"&gt;.sidebar-toggle-mobile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.app-bar&lt;/span&gt; &lt;span class="nc"&gt;.sidebar-toggle-mobile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two classes beat one, wherever the rule lands in the bundle.&lt;/p&gt;

&lt;p&gt;Verification was done against production-like builds: I served a local production build and confirmed the computed &lt;code&gt;display&lt;/code&gt; was &lt;code&gt;none&lt;/code&gt; at 1400px and &lt;code&gt;flex&lt;/code&gt; at 375px. After deploying, I downloaded the actually-served CSS bundle again and grepped it to confirm the new rule was in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dev builds and production bundles can order CSS differently.&lt;/strong&gt; When something is "only broken in production," look at the deployed bundle itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't fill in the reporter's assumptions yourself.&lt;/strong&gt; "It's a hamburger icon, so it must be a phone" is where the detour started. Ask for device, viewport width, and what exactly appeared on click — first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Correct" fixes are the most dangerous ones.&lt;/strong&gt; Each of my three fixes was a legitimate improvement in isolation. None of them fixed the reported problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decisive clue came from conversation, not code. The three questions to ask before attempting to reproduce: which device, which viewport width, and what exactly did you see when you clicked? Next time, I'm asking those first.&lt;/p&gt;

</description>
      <category>css</category>
      <category>debugging</category>
      <category>frontend</category>
      <category>react</category>
    </item>
    <item>
      <title>Connecting Zenn to a GitHub Repository: The Setup Steps and Two Pitfalls</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Fri, 09 Oct 2026 11:54:46 +0000</pubDate>
      <link>https://dev.to/pythona/connecting-zenn-to-a-github-repository-the-setup-steps-and-two-pitfalls-2i48</link>
      <guid>https://dev.to/pythona/connecting-zenn-to-a-github-repository-the-setup-steps-and-two-pitfalls-2i48</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://zenn.dev/pythona/articles/zenn-devto-velog-crosspost" rel="noopener noreferrer"&gt;my previous article&lt;/a&gt;, I introduced a pipeline that cross-posts from a single GitHub repository to Zenn, dev.to, and velog. I briefly touched on Zenn's GitHub integration there, but that part was actually where I stumbled first. In this post, I'll zoom in on just the integration: the correct setup steps, and the two pitfalls I actually ran into. If you follow the steps in this order, you should be able to avoid the same swamps.&lt;/p&gt;

&lt;p&gt;(For context: Zenn is one of the most popular tech blogging platforms in Japan, and its killer feature is publishing articles by simply pushing Markdown to a connected GitHub repository.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What the GitHub integration gives you
&lt;/h2&gt;

&lt;p&gt;Once connected, pushing Markdown files under &lt;code&gt;articles/&lt;/code&gt; in your repository publishes or updates articles automatically. No web editor needed, and your articles get proper Git history. Private repositories work too, so your drafts and idea notes stay private while only the published articles become public.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correct setup flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Log in to Zenn and open "Deploy from GitHub" in the dashboard (&lt;code&gt;zenn.dev/dashboard/deploys&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Start the connection &lt;strong&gt;from the "Connect repository" button on Zenn's side&lt;/strong&gt;, then pick the target repository on GitHub's authorization screen (I recommend granting access to only the selected repository, not all repositories)&lt;/li&gt;
&lt;li&gt;After the connection completes, pushing an article triggers a deploy, and the deploy log shows up in the dashboard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It looks completely unremarkable — the point is that you &lt;strong&gt;must start from Zenn's side&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Installing the app from GitHub's side doesn't connect anything
&lt;/h2&gt;

&lt;p&gt;This was my first mistake. I installed the Zenn Connect app from the GitHub Marketplace first, and the connection never showed up in Zenn's dashboard. No error message either, so I had no idea what was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause&lt;/strong&gt; is how the connection flow is designed. Zenn's GitHub integration assumes the OAuth authorization and repository selection happen from Zenn's own screen. Installing the app from GitHub's side alone never creates the link to your Zenn account. The official docs even state explicitly: if the app is already installed and you see this screen, uninstall it once and reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: uninstall Zenn Connect from GitHub's Settings → Applications, then redo the connection from Zenn's "Connect repository" button. After that, it showed up in the dashboard immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: Commits pushed before connecting never get deployed
&lt;/h2&gt;

&lt;p&gt;Just when I thought I was done, the next trap. Articles I had pushed &lt;strong&gt;before&lt;/strong&gt; connecting never got deployed, no matter how long I waited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause&lt;/strong&gt;: only pushes made after the connection is established are detected as deploy targets. Existing commits are not processed retroactively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: push a new commit after connecting. An empty commit is enough.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"trigger zenn deploy"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That triggered the first deploy, and my existing articles finally appeared in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  How article files work
&lt;/h2&gt;

&lt;p&gt;Deploy targets live at &lt;code&gt;articles/&amp;lt;slug&amp;gt;.md&lt;/code&gt;. The slug must be &lt;strong&gt;12–50 characters of lowercase letters, digits, and hyphens&lt;/strong&gt; — anything outside that range fails at deploy time. It's easy to pick a slug that's too short, so watch out.&lt;/p&gt;

&lt;p&gt;The front matter looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Article&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;title"&lt;/span&gt;
&lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔗"&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tech"&lt;/span&gt; &lt;span class="c1"&gt;# tech or idea&lt;/span&gt;
&lt;span class="na"&gt;topics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zenn"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# lowercase, up to 5&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# false means draft&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pushing with &lt;code&gt;published: false&lt;/code&gt; keeps the article as a draft, so it's safest to verify your deploy setup with a draft first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Always start the connection &lt;strong&gt;from Zenn's "Connect repository" button&lt;/strong&gt;. If you installed the app from GitHub's side first, uninstall it and reconnect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commits pushed before connecting are never deployed.&lt;/strong&gt; Push an empty commit after connecting to trigger the first deploy&lt;/li&gt;
&lt;li&gt;Slugs are 12–50 characters of lowercase letters, digits, and hyphens. Use &lt;code&gt;published: false&lt;/code&gt; for drafts&lt;/li&gt;
&lt;li&gt;Private repositories work, so only the articles go public while your writing history stays private&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the way, the pushes to this repository are themselves automated as a scheduled task run by an AI agent. The full picture is in &lt;a href="https://zenn.dev/pythona/articles/zenn-devto-velog-crosspost" rel="noopener noreferrer"&gt;my previous article&lt;/a&gt; if you're curious.&lt;/p&gt;

</description>
      <category>zenn</category>
      <category>github</category>
      <category>blogging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Multilingual Blog Pipeline That Auto-Posts to Zenn, dev.to, and velog</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:54:30 +0000</pubDate>
      <link>https://dev.to/pythona/building-a-multilingual-blog-pipeline-that-auto-posts-to-zenn-devto-and-velog-246p</link>
      <guid>https://dev.to/pythona/building-a-multilingual-blog-pipeline-that-auto-posts-to-zenn-devto-and-velog-246p</guid>
      <description>&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;As a Korean engineer working in Japan, I've long wanted to publish technical articles in three languages: Japanese on Zenn, English on dev.to, and Korean on velog. Manually copy-pasting every article into three platforms was never going to happen, so I built a pipeline with a single GitHub repository as the hub — push once, and the article goes out to all three platforms automatically.&lt;/p&gt;

&lt;p&gt;I expected Zenn to be the easy part and got tripped up anyway, and velog turned out to have no official API at all. Here are the pitfalls I actually hit and how I solved them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overall architecture
&lt;/h2&gt;

&lt;p&gt;The repo layout is simple — one directory per platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── articles/   # Zenn (auto-deployed via GitHub integration)
├── devto/      # dev.to (GitHub Actions + official API)
├── velog/      # velog (GitHub Actions + GraphQL)
└── material.md # topic backlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I push an article, three things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zenn&lt;/strong&gt;: the official GitHub integration detects changes under &lt;code&gt;articles/&lt;/code&gt; and deploys them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dev.to&lt;/strong&gt;: a GitHub Actions workflow posts files under &lt;code&gt;devto/&lt;/code&gt; through the official REST API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;velog&lt;/strong&gt;: a workflow posts files under &lt;code&gt;velog/&lt;/code&gt; through velog's internal GraphQL API&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Zenn's GitHub integration must be started from the Zenn side
&lt;/h2&gt;

&lt;p&gt;My first stumble was Zenn itself. If you install the Zenn Connect app from the GitHub Marketplace side first, the integration never shows up in Zenn's dashboard (&lt;code&gt;zenn.dev/dashboard/deploys&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause&lt;/strong&gt; is the direction of the flow. Zenn's integration is designed so that the OAuth authorization and repository selection happen from Zenn's settings screen. Installing only the GitHub App from the GitHub side never creates the link to your Zenn account. Zenn's docs even say explicitly: if the app is already installed and you see this screen, uninstall it first and reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; was simple: uninstall the app on the GitHub side, then redo the integration from the "Connect repository" (リポジトリを連携する) button in Zenn's settings.&lt;/p&gt;

&lt;p&gt;One more gotcha: &lt;strong&gt;commits pushed before the integration are never deployed&lt;/strong&gt;. Only pushes after the connection is established get picked up, so if you have existing articles, you need a fresh commit after connecting. An empty commit works fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"trigger zenn deploy"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That kicked off the first deploy and my articles appeared in the dashboard. A nice bonus: private repositories work too, so your drafts and commit history stay private while only published articles go public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: dev.to wants the front matter inside the body
&lt;/h2&gt;

&lt;p&gt;dev.to was the pleasant one, since there's an official API. The key point is that instead of sending title and tags as separate fields, you send &lt;strong&gt;the entire Markdown including front matter as &lt;code&gt;body_markdown&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://dev.to/api/articles"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"article": {"body_markdown": "---\ntitle: ...\npublished: true\ntags: ...\ncanonical_url: https://zenn.dev/...\n---\n\nBody here"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field is &lt;code&gt;canonical_url&lt;/code&gt;. Publishing the same content on multiple sites risks being treated as duplicate content by search engines, but pointing &lt;code&gt;canonical_url&lt;/code&gt; at the Zenn original declares "this is the canonical source." Also, &lt;code&gt;published: false&lt;/code&gt; lets you post as a draft, so it's safest to test the pipeline with drafts first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: velog has no official API
&lt;/h2&gt;

&lt;p&gt;velog was the hard one. There is no official API, so I ended up calling the internal GraphQL endpoint used by the web frontend (&lt;code&gt;v3.velog.io/graphql&lt;/code&gt;), authenticated with the login session cookies (&lt;code&gt;access_token&lt;/code&gt; / &lt;code&gt;refresh_token&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Then I hit a baffling behavior: if you pass &lt;code&gt;null&lt;/code&gt; to the &lt;code&gt;meta&lt;/code&gt; field of the &lt;code&gt;writePost&lt;/code&gt; mutation, &lt;strong&gt;you get no error — just a &lt;code&gt;null&lt;/code&gt; result&lt;/strong&gt;. With no error message, I burned a lot of time figuring this out. The answer: pass an empty object &lt;code&gt;{}&lt;/code&gt; instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;writePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;is_temp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c"&gt;# null fails silently&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;url_slug&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token handling has its quirks too. The &lt;code&gt;access_token&lt;/code&gt; is short-lived, but if you send the expired one together with the &lt;code&gt;refresh_token&lt;/code&gt;, fresh tokens come back via &lt;code&gt;Set-Cookie&lt;/code&gt;. The workflow is supposed to pick those up and update the secrets — but the refresh token itself expires after roughly 30 days, so &lt;strong&gt;you have to manually re-grab the cookies about once a month&lt;/strong&gt;. That's the operational weak point. For testing, &lt;code&gt;is_temp: true&lt;/code&gt; saves the post as a draft, which is what I used during verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational details
&lt;/h2&gt;

&lt;p&gt;One small but important detail: the Actions workflow only processes &lt;strong&gt;newly added files&lt;/strong&gt;. Otherwise, every typo-fix push would re-post the same article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A HEAD^ HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'devto/*.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All API keys and cookies live in GitHub Actions Repository Secrets, injected as environment variables in the workflow. Never commit them in plaintext — and be careful not to echo them into logs either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start Zenn's GitHub integration &lt;strong&gt;from the Zenn side&lt;/strong&gt; ("Connect repository" button). Pre-integration commits never deploy, so push an empty commit after connecting&lt;/li&gt;
&lt;li&gt;For dev.to, put the front matter inside &lt;code&gt;body_markdown&lt;/code&gt; and use &lt;code&gt;canonical_url&lt;/code&gt; to avoid duplicate-content issues&lt;/li&gt;
&lt;li&gt;velog requires the unofficial GraphQL API; pass &lt;code&gt;{}&lt;/code&gt; (not &lt;code&gt;null&lt;/code&gt;) as &lt;code&gt;meta&lt;/code&gt;. Token expiry is the operational weak point&lt;/li&gt;
&lt;li&gt;Process only newly added files (&lt;code&gt;--diff-filter=A&lt;/code&gt;) to prevent double-posting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One last thing: the writing and translation side of this pipeline is handled by a scheduled AI agent (Claude) task. Every night it reads the topic backlog (&lt;code&gt;material.md&lt;/code&gt;), writes an article, generates all three language versions, and pushes them. I'll cover that part in detail in a future post.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>githubactions</category>
      <category>blogging</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
