# Field Learnings — `design-spacing-rhythm` Append-only journal for the **§15 Continuous-Improvement loop** in `skills/spacing-skill/SKILL.md`. Every real spacing/alignment edit lands here as one dated entry; lessons that generalize are distilled into the skill (with a `CHANGELOG.md` bump) and then pruned here to a one-line pointer. > Accessibility floors (§9) outrank any preference recorded in this file. Log > only edits you actually made — **evidence, not vibes.** ## How to add an entry 1. Make (and ideally render) the spacing/alignment edit. 2. Append one entry at the **top** of *Entries* using the template below. 3. If the verdict is `gap` or `refinement` **and** the lesson generalizes, fold it into the right section of `SKILL.md`, bump `CHANGELOG.md`, then replace the entry here with a one-line pointer: `→ folded into §n @ vX.Y.Z`. ### 2026-09-24 — CongCuDoiTen / WinForms results table with a new image column — a resize is a round trip: sizes the toolkit measured while narrow stay cached when the table widens → folded into §8.I - Space Read: offline WinForms tool, results table (checkbox · corner-number picture · catalog no. · name · status · file · score) beside a preview and a catalog pane · balanced · rows 54 px (48 px picture + 6) · DENSITY 6 · RIGOR 8 · every row the same height unless its text truly needs more; the three text columns keep their ratio 46 : 30 : 18 at every window size from 1000×600 to 1680×940. - Did: added a 120×48 picture column that shows at full size when the table has room, draws zoomed with a short header when it is tight, and hides below 72 px; the thresholds use the table's client width minus the vertical scrollbar, so the column does not flip when the scrollbar appears. Then drove a narrow → wide → narrow round trip at five window sizes and measured every row, including rows scrolled out of view. Two cached sizes survived: (1) rows that grew to 78 px at 1000 px kept 78 px at 1680 px where they needed the 54 px floor — `AutoResizeRows(DisplayedCellsExceptHeaders)` changed nothing, because while an autosize mode is active WinForms only caches heights set through the public API; (2) the fill columns came back as 216 · 151 · 112 px instead of 226 · 157 · 96 px at the same 1366 px, because the File column hit its minimum at 1000 px and the grid kept the adjusted working ratio. Fix: one pass 150 ms after a text column stops changing width — nudge one FillWeight up and back (the grid redistributes from the weights; now 235 · 153 · 91 → 234 · 153 · 92, ≤ 2 px of rounding), then autosize mode to None, `AutoResizeRows(AllCellsExceptHeaders)`, mode back (39 ms at 50 rows, 131 ms at 300; a drag step stays ~15 ms). Two follow-ups from an adversarial review: resetting the ratio on every resize step made window drags ~58% slower at 300 rows, so it moved into the settle pass; and the first version of that pass re-armed itself through its own width-changed events, firing every 150 ms forever (the harness waiting for an idle table timed out at 240 s) — the pass now ignores the events it raises and re-measures only when widths really moved. Hiding the picture column while it held the current cell also cleared the grid's current row (F8 and arrow keys restarted from the ends), so the current cell moves to the name cell first, keeping selection and scroll. - Taught: a layout checked once at each size can still be wrong after the user resizes, because toolkits keep what they measured while narrow: row heights of rows that were visible, auto-sized controls that only grow, fill ratios adjusted at a minimum width. Virtualized lists on the web cache measured heights the same way. Re-measure every item, off-screen ones included, once the width settles; test the round trip, not each size; and make sure the settle pass cannot trigger itself. - Verdict: gap(§8) — second CongCuDoiTen datapoint for "sizes stay cached across a resize" (the first was the `GrowOnly` toolbar in the entry below). - Action: folded into §8.I (new), the §13.C Responsive checklist and anti-pattern #25 @ v2.13.0. Verified by the tool's harness, 58/58 at 1000×600, 1200×700, 1280×720, 1366×768 and 1680×940. ### 2026-09-24 — CongCuDoiTen / WinForms three-pane window (table · preview · catalog) — a label that grows with data needs its widest text reserved, and "fits?" must be measured against the slot, not the auto-sized control - Space Read: offline WinForms tool, three panes side by side under one toolbar band · balanced · STEP 3 px control margins, 12 px outer gutter · DENSITY 6 · RIGOR 8 · pane headings and second rows must share one line across all three panes; nothing may clip or wrap at 1000×600 (minimum) … 1680×940. - Did: measured control bounds in the running form at 1000/1366/1680/maximized instead of eyeballing. At 1680×940 every outer edge sits at x=12 / x=1652, the toolbar and both pane titles share centre y=134, second rows start at y=157, picture and catalog box end at y=790. Three dynamic-text fixes: (1) the table toolbar wrapped (42 → 80 px, table header dropped 38 px below the other panes) as soon as 2 rows were selected, because `Chọn loại tài liệu…` became `…cho 2 dòng…` (127 → 193 px) and New-Nut buttons use AutoSizeMode `GrowOnly`, so the bar stayed wrapped after the selection went back to one row; kept the label constant (count moved to the tooltip) and pinned the other counter button to the width of its widest caption (`▶ Cần xem: 999 dòng (F8)`). (2) The step strip, the name-column header and the footer count line got short variants chosen by measuring the long text against the width the parent cell allots (`TableLayoutPanel.GetColumnWidths`), not against the control's own auto-sized width. (3) A rules line measured 255 px by `TextRenderer.MeasureText` still wrapped under `MaximumSize.Width = 258`, because an AutoSize `Label` adds ~7 px around its text; the fit test now adds 8 px. - Taught: (a) text that changes with data (counts, selection size) inside a no-wrap row must either stay constant or reserve its widest value, and `GrowOnly` autosize never gives the space back, so one transient long label breaks alignment for the rest of the session. (b) Choosing a short text variant by comparing with the control's own auto-sized width is self-referential: once short, the control shrinks and the long text never fits again; compare with the slot the container gives. (c) A text-fits check must include the host control's own padding, not just the renderer's glyph width. - Verdict: covered(§4.C — one shared band across panes, measured) · refinement(§9.D/§14 — verify dynamic text at its widest value, and measure fit against the allotted slot including control padding). - Action: pending pattern — first datapoint for the autosize-`GrowOnly` and self-referential-fit traps; per §15.D fold into §9.D on a repeat. Verified by the tool's harness (43/43 at 1366×768 and 1680×940) plus probes at 1000–1680 px wide. → the `GrowOnly` half repeated the same day (grid rows and fill ratios kept across a resize) and was folded into §8.I @ v2.13.0. ### 2026-09-22 — PCC4SH / kho-vat-tu.xlsm bảng lịch sử hiện tại chỗ dưới bảng tồn kho và dưới tờ phiếu — "below the last filled cell" is not "below the layout" - Space Read: Excel workbook; a button pastes a hidden history table underneath whatever the reader is looking at (stock list or a 02-VT invoice form) and pastes it away again on the next press · balanced · STEP 1 row. - Did: placed the pasted table `last used row + 2 blank rows + 1`, where "last used" came from `Cells.Find("*")`. On the invoice sheet that landed the table on row 36 — the signer-name row, which is *inside the print area* but empty on the blank template — and collapsing the table deleted that row. Fixed by taking `max(last filled row, bottom of PageSetup.PrintArea)` as the anchor (invoice: 37 → table starts at 39). Second bug: the italic hint under the title inherited a right-aligned column A on the stock sheet, so the overflow ran left off the sheet and read as "bấm để thu gọn bảng này." (front half missing); forced `xlLeft`. - Taught: the bottom of a form is defined by its **print/layout box**, not by its last non-empty cell — a template's reserved rows are blank by design. And overflow direction follows text alignment: a long label in a narrow left column must be left-aligned or it spills into nothing. - Verdict: refinement(§4 — a reserved-but-empty slot still occupies space; anchor "after" on the declared box) · covered(§9/§14 — render with real data; the blank template hid both bugs, the COM run with three filled rows exposed them). - Action: `ModKho.HienBangTaiCho` anchors on `max(Find, PrintArea)`, hint cell `HorizontalAlignment = xlLeft`; verified by running the macros through Excel COM and `CopyPicture` (PR #425). Not bumping the skill version — folding into §4 wording later if it recurs. ### 2026-09-22 — PCC4SH / kho-vat-tu.xlsm trang Tồn kho + nút quay về — a width table keyed by column letter drifts from a header list keyed by name - Space Read: Excel workbook the warehouse staff download from the web (Rules §0.49: must mirror the web table) · balanced · STEP 1 char (Excel column units) · DENSITY 5 · RIGOR 7 · column widths by content, buttons 150×22pt in a 26pt row - Did: the owner reported "vẫn còn mấy chỗ bị mất chữ". Measured every button caption against its box (TextRenderer vs `btn.Width`) and every cell in the first 12 rows on all 22 sheets: no caption was cut, no title clipped. Then screenshotted each sheet through Excel COM (`Range.CopyPicture`, which includes form buttons the PDF export drops) and the defect was on **Tồn kho**: the name column ("Tên nhãn hiệu và quy cách") was 14 chars wide and "ĐVT" was 50 — the `Set-Widths @{A=6;B=18;C=14;D=50;…}` table had been written for an older header order and never re-read when a column was removed, so it was off by one. Set `C=50, D=8, E=14, F=12, G=18, H=16` and re-aimed the alignment ranges the same way (names left, numbers right, ĐVT centre). Also the "◀ VỀ TỒN KHO" button on the ledger sheets was 20pt tall around 11pt bold text (26px box, 20px glyphs): raised to 22pt, still inside the 26pt row. - Taught: a width table addressed **positionally** (column letters) next to a header list addressed **by name** is two sources of truth, and removing one header silently shifts every width after it by one column. The template looked fine empty — headers wrap, so the narrow name column still read "Tên nhãn hiệu và quy cách" on two lines — and only the *filled* download showed names truncated. The same file already had a by-name `Get-KhoColSpec` table for the other sheets; Tồn kho bypassed it. Second: measuring text-vs-box catches clipped captions but not *wrong* boxes; a screenshot of the rendered artifact with real content is the only check that sees a column that is merely too narrow for what will go in it. - Verdict: covered(§1 — widths by content, not one number) · refinement(§14 — verify on the rendered artifact with real content; an empty template hides a narrow column) - Action: pinned in `_kho-xlsm.test.ts` (name column ≥40, ĐVT ≤10, name widest). Switching the sheet to by-name specs is the follow-up, not done yet. No SKILL bump — §14 already says render-and-measure; the new wrinkle is only that an empty template is not the artifact. ### 2026-09-20 — spacing-skill / video giới thiệu repo (một template, hai khung 1920×1080 + 1080×1920) — an auto-margin with no slack is `0`, and a burnt-in caption is a safe area - Space Read: title-and-panel slide deck for a 2-minute repo video, one HTML template rendered at both aspect ratios · balanced · STEP 8 · DENSITY 3 · RIGOR 7 · 64px background grid; ladder 8 → 24 (inside panels) → 64 (column gap) - Did: three spacing bugs, each caught only by looking at the rendered artifact. (1) Titles were set `line-height:1.08`; on Vietnamese all-caps two-liners the `Ề` of "ĐỀU NHAU" collided with "PADDING" above it — the skill's own anti-pattern #15, committed while building a video that *teaches* anti-pattern #15. Raised to `1.15`. (2) The copy column's footer used `margin-top:auto`; it rendered 44px clear in the landscape build and **0px** in the portrait build, footer jammed against the subtitle — portrait made that column `flex:none`, so there was no free space for `auto` to eat. Replaced with a literal `44px`. (3) Burnt-in ASS captions (46px, `MarginV 56`) landed on top of the panels: the page reserved only 72px of block-end padding, so the caption box covered the last two lines of the SLOP panel's note and, in portrait, the `line-height 1.15` badge — the exact evidence the slide existed to show. Reserved a real band: block-end padding 72 → **184px** (landscape) and 88 → **208px** (portrait), caption `MarginV` 56/64. - Taught: two failure modes that share one shape — *a spacing value that is computed from leftover space, or from an assumption about empty space, is not a spacing value.* `margin-top:auto` is the first: it is a claim on free space, so the identical declaration yields 44px or 0px depending on whether an ancestor happened to stretch the box, and it fails silently in the variant you did not look at. A composited overlay is the second: `env(safe-area-inset-*)` covers the notch but knows nothing about a caption, HUD, or control bar the renderer paints later, so the layout verifies clean in the browser and ships broken in the frame. Both are invisible to every check performed on the page rather than on the finished output. Third, smaller, lesson: shipping a skill's own anti-pattern inside that skill's marketing is the cheapest possible proof the rule is worth having — and it took a rendered screenshot, not a code review, to catch. - Verdict: gap(§4.C, §8.E) · covered(§5.A — #15 fired exactly as written) - Action: folded into §4.C (auto-margin needs slack, with the 44/0 measurement) and §8.E (composited overlays are safe areas, with the 72 → 184px measurement) @ v2.12.2, plus anti-patterns #23 and #24. Both are failure-mode warnings from real defects, hardened on first occurrence per the §15.D carve-out. ### 2026-09-09 — PCC4SH / màn Cài đặt + menu tài khoản — `w-auto` loses to the Stack, and a leading icon moves the spine - Space Read: settings screen (PWA mobile-first, also used on desktop) · balanced · STEP 4 (inherited Tailwind v4 + PCC4SH tokens) · DENSITY 5 · RIGOR 8 · ladder 8 → 16 → 24; every tap target ≥48px and ≥12px apart (the brief's a11y floor outranks every dial) - Did: the screen was four white cards stacked, each holding exactly one full-bleed navy 64px button, so nothing read as more or less important than anything else — textbook anti-pattern #7 (a card per group) plus #1 (uniform weight). Collapsed them into three labelled cards ("Tài khoản" / "Trên máy này" / "Thoát tài khoản") whose members are rows: 44px icon tile, title + description, then the action. Secondary actions moved to a new 48px `inline` size (`w-full sm:w-auto`) instead of the 64px full-bleed primary. Two alignment bugs surfaced only after rendering: (1) at 1280px the `inline` buttons were still 640px wide — `sm:w-auto` was set but the row is a `flex-col`, so `align-items:stretch` overrode it; `sm:self-start` fixed it to 184px. (2) the action sat at the card inset while its own description started 56px in, behind the icon tile — added `sm:pl-14` (44 + 12) so title, copy and control share one left edge (measured descLeft 554 = btnLeft 554). - Taught: `width` is a main-axis word. In a vertical Stack the cross axis belongs to `align-items`, so every "hug your content" utility is a no-op until an `align-self` backs it — and the failure is invisible on mobile, where full-bleed happened to be the intent, then appears at the first wide breakpoint. Second: a leading icon silently defines a text column, and any control that is a sibling of the icon block re-aligns to the container inset instead. The two edges are 56px apart, which reads as sloppy rather than as a deliberate outdent. - Verdict: gap(§4.C) - Action: folded into §4.C @ v2.11.0 — both bullets. Visible-misalignment failure modes caught on first render, so hardened on first occurrence per the §15.D carve-out. - Follow-up (same day, after the owner asked for a different look): the row/button split was dropped entirely for the **whole row as the tap target** — 64px rows carrying icon tile, one-line label and a chevron, with the long explanation moved to a note at the foot of the group. Measured 341×65px per target versus 279×48px before, ~1.7× the area, and the §4.C indent problem stopped existing because there is no separate control to align. Kept both §4.C bullets: the `align-self` trap is general, and the measurement stands. Added the row-as-target rule to §10.B @ v2.12.0. ### 2026-09-05 — PCC4SH / Mẫu 02-VT print margins — the page margin box is also the browser's header/footer canvas - Space Read: printed A4 accounting form · balanced · STEP 4 · RIGOR 8 · paper margin must stay ~10mm, but nothing may print inside it - Did: the user saw `11:52 5/9/26 · Hệ thống quản lý công trình` across the top of every printed slip and the page URL across the bottom. Those are Chrome's own header/footer, drawn **inside the `@page` margin box** — not content, so no selector reaches them. Moved the slip to a named page `@page phieu-mot-trang { margin: 0 }` and put the 10mm back as `padding` on the print root (`main[data-print-page='phieu-kho']`). Kept the default `@page { margin: 10mm }` for the multi-page reports. - Taught: `@page` margin is doing **two** jobs — paper whitespace *and* the browser chrome's canvas. Zeroing it is the only CSS lever that removes the chrome, and the whitespace then has to be re-created as padding on the content. The catch that decides where you can apply it: **body/root padding is not per-page.** Left/right padding repeats on every sheet, but top/bottom lands only on the first and last page — so a multi-page document with `margin: 0` prints its middle pages edge-to-edge and the printer's hardware margin clips the top and bottom rows. Safe for a one-page form, a defect for a long report. Scope the named page accordingly, and prefer the failure that adds whitespace (named page ignored ⇒ 10mm margin + 10mm padding) over the one that removes it. - Verdict: gap(§12) - Action: folded into §12 @ v2.10.0 — added the print-margin dual-role rule and anti-pattern #22. A user-visible defect with no selector-based fix, so hardened on first occurrence per §15.D's failure-mode carve-out. ### 2026-09-05 — PCC4SH / Mẫu 02-VT slip signature row — bottom-aligned boxes ≠ bottom-aligned text → folded into §4.C - Space Read: printed accounting form, 4-column signature block · balanced · STEP 4 · DENSITY 4 · RIGOR 8 · four signer names must land on one baseline; the gap above them is hand-signing space - Did: signature row was a 4-track grid, each column `flex-col` with `margin-top:auto` on the name. Two columns held `` (`min-height:44px`, `padding:.3rem .2rem`), two held a bare `` (~22px, line-height 1.3 × 1.05rem). Both flush at the bottom, yet the input text sat ~11px higher — the user rejected it on sight. Fix: `.mau02vt-signature-name { min-height:44px; padding:.3rem .2rem; text-align:center }` on **all four**, plus `span.mau02vt-signature-name { display:flex; align-items:center; justify-content:center }`. Print keeps only the spans, so `@media print` resets `min-height:0; padding:0`. - Taught: `margin-top:auto` bottom-aligns the **box**; the reader sees the **text**. Any row mixing an editable control with static text inherits the control's touch-target `min-height` (44/48px) as an alignment constraint — the control centers its text inside that tall box, the span fills its short one. Invisible until the two sit side by side. The a11y floor (§9) silently authored a layout rule. - Verdict: gap(§4.C) - Action: folded into §4.C @ v2.9.1 — added the mixed-control/text bullet and anti-pattern #21. A visible misalignment the user caught on first render → hardened on first occurrence, per the §15.D carve-out for failure-mode warnings. ### 2026-09-01 — PCC4SH / shell taskbar — a centered clock needs balanced grid tracks and a compact mobile face - Space Read: authenticated app shell header · balanced · STEP 8 (Tailwind 4px utilities, 8px layout rhythm) · DENSITY 4 · RIGOR 8 · title/control groups stay at the edges while the clock owns the center track - Did: changed the header row from one flex line to `grid-template-columns: minmax(0,1fr) auto minmax(0,1fr)` with `gap-2` (8px) below `sm` and `gap-3` (12px) from `sm` upward. Measured the built CSS at 320px: header inner row 288px after 16px side padding, clock 65.86px, clock center delta **−0.0078px**, edge overlap **0px**; the clock keeps only `HH:mm` visually while its DOM/accessible label retains seconds. Measured the desktop shell at 1440px with the 368px navigation rail: taskbar content center x=904, clock center x=904, delta **0px**, overlap **0px**, inner row 1040px. - Taught: the first symmetric grid was mathematically centered but left only 92px per side at 320px while the sync icon + avatar cluster needed 104px, creating a 4px collision. A responsive clock face is the smallest change that preserves the center track, the existing controls, and the 48px touch floor; keep the full seconds display where the side groups have room. - Verdict: covered - Action: none ### 2026-08-30 — PCC4SH / login screen — unlayered critical CSS beats `@layer utilities` → folded into §12 - Space Read: `auth form · balanced · STEP 4 (Tailwind inherited) · DENSITY 4 · RIGOR 8 · label↔input 8 < field↔field 20 < group↔group 32` - Did: user reported the login field's text "starts small, then grows a moment later". Measured by disabling the built stylesheet in the live page: field `13.33px` (UA default for ``; box 22px tall) vs `22px` with CSS (box 60px) — the only element on the screen whose size visibly changes, because the UA sheet renders body text at 16px but form controls at ~13.3px. Fixed delivery two ways: `experimental.inlineCss` (production; CSS ships inside the HTML, no render-blocking round trip) plus a two-line critical `