Skip to content

What's New in M365 Copilot — Monthly Pack Playbook

Deep playbook for the whats-new-copilot-pack skill (~/.copilot/skills/whats-new-copilot-pack/). Read the SKILL.md for the operational steps; read this for the why behind every design + engineering decision, and for the QA discipline. Born 25 Jun 2026 building the first (June 2026) pack. Expanded 22 Jul 2026 with the blog/screenshot pipeline, revised 24 Jul 2026 with the complete July pack retrospective + August fast path, then revised 31 Jul 2026 with the full-edition LinkedIn carousel workflow.

Monthly production pipeline: research → blog (§9) → screenshots (§10, reused by the pack) → pack (§§1-7) → LinkedIn carousel. The blog is the content source for the pack; do it first and finalise it before building slides.

1. Origin & purpose

Sush publishes a monthly "What's New in Microsoft 365 Copilot" pack under his own name on aguidetocloud.com, built from that month's public blog recap. The brief evolved from "match the layout of the template we started from" → "build a premium editorial monthly publication readers eagerly wait for."

The deck is generated programmatically with python-pptx from the month's blog recap (/blog/microsoft-365-copilot-<month>-2026-updates/). The design engine is stable, but the content module and hardcoded special slides change each month. The final July scripts are the current baseline.

2. Design philosophy

  • Editorial, not a press release. It reads like a curated monthly magazine: cover with "Inside this issue", an editor's note, a dashboard, sections, a closing.
  • Two-panel rhythm. Every content slide = LEFT white text column / RIGHT ivory notebook-grid panel (screenshots or designed visuals). Boundary at x = 4.975".
  • Restraint = premium. Muted ink/navy/brass on warm ivory paper; soft shadows for depth; small status pills. Editor's picks are impact-based — at most one per major content section when they genuinely help navigation. Earlier "stripped bare" and "busy/funky" iterations were both rejected — the landing zone is crafted but quiet.
  • Every element earns its place. No masthead strip, no logo clutter, no kicker noise, no footer cruft. (These were explicitly removed during iteration.)
  • Sush's voice throughout. Humble curious-intern, plain English, no jargon, no brag. A "Why it matters" note in his voice on every feature and section.

3. Design DNA (the non-negotiables)

Element Value
Slide 13.333"×7.5" (16:9), rendered 1280×720
Palette ink #1B1B1B · paper #F4EFE3 · navy #22385C · brass #A6824C · card #FDFBF6 · hairline #E4DFD3
Fonts Segoe UI Semibold (titles) · Segoe UI (body 11pt) · Ink Free (handwritten "Why it matters" + "— Sush")
Fixed anchors title 0.5" · why-card 3.45" · do-next 5.82" · links 6.92" → identical alignment on every feature slide
Panel full-bleed grid-panel.png (ivory + faint notebook lines) gives subtle depth under screenshots
Pills small; filled navy = GA/Available, outline = Rolling out/Preview

4. Technical architecture

build_pack_<month>.py   CONTENT (changes monthly) → imports the engine, defines the data, saves+cleans
  └─ deckbuild.py        template load · _new_slide · text helpers · estimate_title_lines (size-aware) ·
  │                       est_body11 · new_deck (strips slides + sections) · set_core_props · clean_app_xml
  └─ premium.py          base helpers: P.ASSETS, card(), fit_in_box()
  └─ premium4.py         crafted engine + hardcoded cover: feature4, cover4, why_card, place_framed, add_shadow,
  │                       small_pill, navy_links, page_number, fixed anchors, palette
  └─ premium4_slides.py  hardcoded monthly special slides: editor4, dashboard4, opener4, matrix4, whatchanged4,
                          roundup4, divider4, closing4, feature_visual + vis_* designed visuals
- Rendering = PowerPoint COM via PowerShell (New-Object -ComObject PowerPoint.Application → .Slides.Item(i).Export(png,"PNG",1280,720)). python-pptx's thumbnail.py fails on Windows (AF_UNIX). Render time scales with the issue; July's 46 slides remained practical. - Blank template (assets/template_blank.pptx, 70 KB) = the starting template with all content slides stripped (master + layouts only; layout "3-Item-Template" carries the panel). Avoids bundling any prior content + keeps the skill tiny. Verified to produce pixel-identical output to building from the full template. - Engine paths are env-overridable: WNP_TEMPLATE, WNP_ASSETS, WNP_OUT.

5. Gotchas (every one cost real debugging — don't relearn them)

  1. Title overflow / title-touching-body is the recurring bug CLASS. Root cause (found 25 Jun): estimate_title_lines measured at a 24pt-calibrated font, but titles render at 27pt (features) / 28pt (dividers). Long titles were under-counted as 1 line → body_y placed too high → title and body touched (e.g. "The Work IQ APIs are GA"). Fix: the estimator is now size-aware (size= param scales the measured width); every caller passes its true render size. This prevents the whole class for all future months. If a title still wraps to 3 lines (e.g. "Copilot inside model-driven Power Apps"), shorten the title ("inside"→"in") — don't shrink the font.
  2. PowerPoint sections leak from the template. "Default Section" / "Microsoft 365 Copilot" / "ADMIN" showed in the slide pane. deckbuild.strip_sections() removes the p14:sectionLst ext in new_deck().
  3. Stale metadata. The starting template carried a previous author in dc:creator, revision 96, and an app.xml claiming 44 slides + May titles + a 4349-min edit timer. set_core_props() (author=Susanth Sutheesh, title, revision 1, fresh dates) + clean_app_xml() (minimal app.xml, correct slide count) run at save.
  4. Unsaved-edits quirk. If the user has the pptx open in PowerPoint: COM render shows their unsaved edits, but python-pptx reads stale disk (measure intent from renders). And Copy-Item to an open file fails ("being used by another process") → deliver under a fresh versioned name (...PREMIUM v2/v3/v4.pptx).
  5. Multiple images per feature → place_framed lays them side-by-side / in a grid to fill the panel, not squished. Single thin/portrait shots inherently leave some panel space — acceptable (matches the original layout).
  6. Screenshot bleed. Source screenshots can include faint background content past the UI element (e.g. the Claude model-picker had chat letters bleeding outside the dropdown). Crop to the actual UI card with PIL before placing.
  7. Chrome vs month assets. m365-logo.png + grid-panel.png are reusable (skill assets/chrome/); the QR is per-month (encodes that month's blog URL — regenerate); screenshots are per-month. All live together in the month's WNP_ASSETS folder at build time.

6. QA discipline (paid-quality, customer-facing surface)

  • Vision-QA every screenshot (Rule #8). Open each with the view tool, write a one-line observation of what's literally pictured, confirm it matches its section. Filenames lie; text-only agents can't see images.
  • Fresh-eyes subagent over every rendered slide. You've stared at the code — a subagent finds what you can't. Priority: title touching/over the 4.975 boundary, title↔body gap, body hidden behind the fixed Why-card, weak screenshot crops, thin left columns, overlaps, page numbers and alignment consistency. Then re-render fixed slides and re-verify (one fix often spawns another).
  • SME fact-check (background research agent). Verify every product name, roadmap ID, GA status, and factual claim against Microsoft Learn release notes (GA source of truth — roadmap status lags) + the roadmap. Customer-facing content under Sush's name = accuracy matters.
  • Privacy pass. Blur colleague identities in any shared screenshot (keep Sush's own name). Flag "INTERNAL" badges for a keep/clone-out decision before customer use.

7. Monthly workflow (short form)

gather blog + screenshots + fresh QR → copy the latest proven month reference → swap MONTH/ISSUE/BLOG + special slides + feature/roundup data → set WNP_* env → build → COM-render every slide → vision-QA + fresh-eyes subagent + SME fact-check → fix/re-verify → deliver to Downloads under a fresh name.

8. June 2026 first-issue facts

17 features + 4 roundups; editor's note pairs with a blurred colleague Teams note; Cowork = editor's pick; dashboard shows 2026-so-far + June; closing QR → the June blog. Actual reference deck = 33 slides, including the public disclaimer slide (the early docs incorrectly said 32). First pack built + shipped 25 Jun 2026.


9. The blog phase (the pack's content source) — added 22 Jul 2026

The monthly recap blog comes first; the pack is built from it. File: aguidetocloud-revamp/content/blog/microsoft-365-copilot-<month>-2026-updates.md.

  • Mirror the previous month's blog exactly as the template. Open last month's .md side-by-side and match structure, frontmatter shape, section rhythm, FAQ, founder_note. Consistency = the monthly-publication feel.
  • Research sources (run in parallel):
  • M365 Copilot release notes on Microsoft Learn = GA source of truth. Published in ~2 batches/month (~1st and ~15th) — cover both. Roadmap status lags the notes; when they disagree, trust the notes.
  • M365 roadmap — query via the mrc-roadmap MCP (free, no auth) for feature IDs to cite (📖 Roadmap NNNNNN) and to catch anything the notes missed. Run two passes: created (what was announced this month) and generalAvailabilityDate (what ships this month) — they return different sets. 🔴 Read mrc-roadmap-mcp-playbook.md first: the results array is items not value, live tools/list beats the Learn doc, and page size caps at 50. A roadmap item is a candidate, never an automatic inclusion — planned ≠ testable.
  • WorkIQ sweep — ask "what's new in Microsoft 365 Copilot this month" to catch anything the notes buried (EULA must be accepted once; get user OK).
  • Official Tech Community monthly roundup from the Microsoft365CopilotBlog board — a separate mandatory source gate below, not covered by a generic "blogs" search.
  • Other Microsoft blogs (microsoft.com/microsoft-365/blog + individual Tech Community launch posts) for GA-day announcements and reusable official infographics.
  • Pricing/licensing: use the official Microsoft pricing page for list price, then a scoped Partner Center announcement for channel/eligibility/promo dates. Every number says USD + billing/term/seat scope.

Official monthly-roundup source gate — mandatory from August 2026

The official "What's New in Microsoft 365 Copilot | " Tech Community post is a required source alongside release notes, roadmap, Work IQ and individual launch posts. It often bundles app-specific details and first-party screenshots that the other sources do not surface together.

Discovery — current month + previous two months

Use all three methods; one empty search is not proof:

  1. Exact web search: site:techcommunity.microsoft.com/blog/microsoft365copilotblog "What's New in Microsoft 365 Copilot" "<Month Year>".
  2. Inspect the official Microsoft 365 Copilot Blog board.
  3. Ask Work IQ whether a true official monthly roundup exists; distinguish it from internal/community decks and single-feature announcements.

For each month, record: URL · title · published date · modified date · retrieval timestamp · FOUND / NOT_YET_PUBLISHED.

If the current-month roundup is not published yet: do not invent a URL and do not block forever. Record NOT_YET_PUBLISHED, continue with the other official sources, then recheck at:

  • month-end;
  • +7 days;
  • +14 days;
  • the next monthly recap run.

If the page's modified date changes, rerun the diff. June's article was published 30 June and revised 14 July — a one-time scrape would still have missed later additions.

Semantic source matrix — blocking content gate

Extract every capability as an atomic row. Split rows when the product surface, actor, rollout timing or action differs (for example: Cowork creates visuals and Cowork uses branded PowerPoint templates are two capabilities).

Required columns:

source month · app/surface · capability · actor · rollout month/status · source URL · FULL/PARTIAL/HORIZON_ONLY/ABSENT · blog disposition · blog location · defer/out-of-scope reason

Definitions:

  • FULL — same capability, surface, actor and timing are materially covered.
  • PARTIAL — the exact item is present but a material detail is missing.
  • HORIZON_ONLY — named only as future/watchlist; this does not count as delivered coverage.
  • ABSENT — not materially covered. Adjacent use of the same word in another app does not count.

Do not lock the blog or start the pack until every row is dispositioned as:

  • included now;
  • explicitly deferred to a labelled catch-up;
  • out of scope, with a reason.

Month purity + late catch-up

  • Never silently relabel a June feature as an August launch.
  • A late official roundup may feed:
  • the correct prior-month recap via a staged backpatch; or
  • a clearly labelled "Late catch-up from Microsoft's official monthly roundup" block in the next issue when the item remains useful.
  • The current issue still leads with genuinely current releases. Catch-up is a small reconciliation layer, not permission to dump every older bullet into the new month.

Official-image harvest

Extract every image URL + Microsoft alt text from the roundup into the issue's image inventory. For every candidate:

source URL · Microsoft-owned proof · literal pixel observation · intended section · USE/SKIP reason · tenant-variance caption

Rule #8 still applies: open the pixels with view; filenames and alt text are not vision QA. Reuse only Microsoft-owned imagery or Sush's demo-tenant captures.

July 2026 audit — why this gate exists

  • Sush's June recap published 24 June.
  • Microsoft's June roundup published 30 June, then changed 14 July.
  • The official post contained 42 atomic capabilities and 30 images.
  • Strict exact-surface comparison across Sush's June + July recaps: 8 FULL · 1 PARTIAL · 5 HORIZON_ONLY · 28 ABSENT.
  • This is not a quality ranking: Sush's recaps also covered many important updates absent from Microsoft's post. It proves the late official roundup held a large different set of app-level details that needed reconciliation.
  • As of 24 July 2026, no official July monthly roundup could be verified after exact web search, official-board inspection and Work IQ search. Status: NOT_YET_PUBLISHED; recheck at month-end, +7, +14 and during the August run.

  • Scope defaults Sush picked: include every material numbered update rather than targeting an arbitrary count; licensing/pricing mid-list; cover through the latest available release-notes batch; text-first (screenshots added after copy is locked).

  • Feature block shape: ## N. <title> · *For: <product> · <platforms>* italic line · body · a <blockquote class="callout callout-tip">💡 <strong>Why it matters:</strong>…</blockquote> in Sush's voice · 📖 [Roadmap NNNNNN](…) links. Then Agents roundup · Admin roundup · On-the-horizon · FAQ. ~5-6k words.
  • Validation gates (all must pass, staged only): node scripts/check-blog-html.mjs → 0 errors; SEO (title ≤60, description ≤155, valid og_glyph, OG image exists on disk — npm run build:og:blog if missing); Hugo via pwsh scripts\hugo-safe.ps1 (never bare hugo). Generate the OG image.
  • SME fact-check = background research agent verifying every roadmap ID + product name + GA status vs the release notes. Real July fixes: dropped an unverifiable UI detail, corrected a model "For:" line, removed a horizon item that turned out to be a prior-month GA. Precision > volume.
  • 🔴 STAGED, NOT LIVE until Sush signs off after multiple quality reviews (Rule #14). Don't deploy the recap the moment it builds.

10. Capturing real screenshots from a demo/lab tenant — added 22 Jul 2026

Real product shots from a Microsoft demo tenant (Contoso/Zava demo data — nothing confidential) beat official marketing images. Two modes:

A. Manual (Sush captures): he shoots on his signed-in tenant → dumps PNG in C:\Users\ssutheesh\Downloads\ → I view (Rule #8), convert to webp, place. Give him a per-feature Downloads\<month>-shots\_CAPTURE-GUIDE.md + the exact demo prompts (below) so he knows precisely what to shoot.

B. Automated (Playwright over CDP) — for features I can drive myself: - Edge persistent profile at <session>/files/edge-lab-profile, launched with --remote-debugging-port=9222. SSO caches in the profile → sign in once, reuse across sessions. - Connect Playwright chromium via CDP at http://127.0.0.1:9222 — NOT localhost (resolves to IPv6 ::1 → refuses). Chromium binary from aguidetocloud-revamp/node_modules/playwright. - Reusable helper <session>/files/_labhelp.cjs (connect / log / sh / settle). Account-picker → click the tenant-admin tile. - ⚠️ NEVER call browser.close() over CDP — it kills the whole browser. Keep it running detached. Close spare tabs via http://127.0.0.1:9222/json/close/{id}. Office web apps (Word/PPT) are CPU-heavy and bog CDP down with many tabs open. - Lab tenant = an M365 Copilot (Premium) demo tenant with admin creds, no MFA. Creds live with Sush — ask him to paste if the profile signs out.

🔴 Rule #8 filename trap (cost time twice in July — now a permanent user memory): the screen-capture tool (Greenshot) names files with the WRONG/stale window title. NEVER judge a shot by filename — always view the pixels. A file named "…Tam Bagnall…Teams.png" was actually a clean PowerPoint Work IQ output deck.

Capturability reality (what a demo tenant can / can't show): - ✅ Chat/Cowork model picker, Outlook Chat, PPT Agent Mode (prompt-driven, Agent Mode is Windows-desktop), image-model picker, Search-by-department, admin center (Prompts for Contoso, Agent Store, DLP/Copilot Control System), Chat image gen / brand kit. - ❌ Not yet rolled out to the tenant (July: Notebooks→Office quick-create + mind maps — the new-notebook menu only had "New Page"). Confirm 2× before marking CANT. - ❌ iPhone/iOS-only (multimodal capture, iOS action button/Siri) → Sush captures on device. - ❌ Needs a special role/license (Viva Copilot Dashboard analyst view, Viva Glint) → usually inaccessible in a lab. - 📝 Text-only (sensitivity-label inheritance behaviour, pricing/SKUs) → no clean product UI; leave as text.

Conventions: lab-NN-desc.webp (tenant) / official-NN-desc.webp (MS official). Convert: Image.open(src).convert('RGB').save(dest,'WEBP',quality=88,method=6). Place with the standard block: <p><img src="/images/blog/copilot-<month>-2026/…" alt="…" loading="lazy" style="max-width:100%;border:1px solid var(--border);border-radius:var(--radius-md);margin:var(--space-4) 0;" /></p>. Input+output pairs read well (prompt shot + result shot).

Model-name nuance: the release-note name may differ from the live tenant picker (July: notes said "MAI-Image-2-Efficient"; tenant showed "MAI Image 2.5 Flash / Flux.2 Flex / GPT-Image"). Keep the release-note fact AND describe what the picker shows — honest to both.

Reusable demo prompts (trigger a feature so it can be captured): | Feature | Prompt / action | |---|---| | Open file in Chat | "Summarise the Office Move Plan and list the key dates" → click the cited file → opens in the side pane | | Outlook whole-inbox | "Summarize the latest updates about <topic> across my whole inbox, and list any action items." | | PPT Agent Mode / Work IQ | "Create a presentation about <topic> using my recent files, meetings and emails." | | Teams → deck | "create a deck from my Teams meeting about <topic> using the style of this presentation." | | Reuse existing deck | attach a deck → "…using the style of this presentation." | | Image model in PPT | "add an image of <x> to this slide" → open the Auto model dropdown | | Search by department | "people in <Dept> department." | | Word Audio Overview | Word web → Audio Overview → generate → ask a question while it plays | | Brand kit from doc | upload a brand-guidelines doc → "Create a brand kit from this document." | | Scheduled prompt | Chat → an agent → schedule a recurring prompt ("every Monday 9am summarise my week") |

11. Red-box annotation — make the "what's new" pop — added 22 Jul 2026

Busy screenshots hide the point. Draw a red rounded-rectangle around the ONE key detail (the new model name, the "whole inbox" phrase, the referenced deck, the department query). Matches Sush's demo-design red-callout convention (red = deliberate "look here"; never for structural chrome). In July this made 7 shots instantly legible.

  • PIL: ImageDraw.Draw(im).rounded_rectangle([x0,y0,x1,y1], radius=8, outline=(220,30,30), width=4).
  • Get real dims first (Image.open(p).size) — the view tool renders at native res, so coords you read off a viewed image map ~1:1 to pixels. Estimate the box, then verify + adjust.
  • Always save a clean backup (*.clean.webp in <session>/files/lab-shots/clean-backups/) before overwriting, so a box can be repositioned.
  • Verify after (Rule #8): view the annotated image; nudge coords if it clips or misses. Mention the highlight in the alt text.
  • Annotate in-place (same webp filename) → no blog markdown edit needed. For prompt shots, box the referenced phrase (e.g. underline/box "from my Teams meeting about the office move").

12. July 2026 issue facts

Final blog

  • 31 numbered sections + Agents roundup + Admin roundup + On the horizon + FAQ.
  • 38 placed images after fresh-eyes cleanup: 7 misleading/weak/unused shots stayed uncommitted.
  • Mixed image sources: Sush's demo tenant + official Microsoft product imagery. Visible global note says tenant UI/availability may differ by rollout.
  • Three editorial picks: Notebooks (headlines), MCP agents in Office + Catalyst (Agents), company-wide Prompt Gallery publishing (Admin).
  • Company-wide prompts are an Admin/governance feature. Agents content closes after #22 Sales Agent; Agents roundup appears before #23.
  • LIVE 24 Jul 2026, commit 4faded47; desktop/mobile 0 overflow, 38/38 images 200, OG/listing/practice/smoke green.

Final pack

  • Sush explicitly chose all 31 numbered sections — no trimming.
  • Final shape = 46 slides:
  • 6 front matter
  • 4 dividers
  • 31 numbered feature slides
  • 3 roundup/watchlist slides
  • closing
  • public disclaimer
  • Slide-count formula for the full edition: N numbered features + 15. For July: 31 + 15 = 46.
  • Final shared file: Downloads\Whats New in M365 Copilot - July 2026.pptx (byte-identical to the v9 copy; SHA-256 D2797FAD...F5F3).
  • 3 editor's picks: Notebooks (slide 9), MCP agents (28), company-wide prompts (33).
  • Every screenshot slide carries: "Demo tenant or official Microsoft imagery · UI and availability may vary by tenant and rollout."
  • Final disclaimer slide covers own opinions, public sources, demo/official imagery, no customer data, and tenant/rollout variance.
  • Final shape = 33 portrait cards: cover + all 31 numbered updates in blog order + closing.
  • Full-edition formula: N numbered updates + 2.
  • Output = 1080×1350 design, rendered at 2160×2700 for a crisp PDF.
  • Final shared file: Downloads\AGTC-Whats-New-July-2026-LinkedIn-Carousel-v2.pdf — 33 pages, 10.8 MB.
  • The carousel reused the final pack builder as structured content instead of manually re-authoring all 31 updates.
  • Microsoft's official July monthly roundup was still NOT_YET_PUBLISHED at the 31 Jul month-end recheck; the live blog + final v9 pack remained the approved source.
  • LinkedIn state: ready for Sush to upload as a document post; not posted by Atlas.

Final facts that changed late

  • OpenAI-operated models became tenant-controlled/auto-enabled for eligible commercial tenants on 24 Jul unless admins choose No users.
  • Capture requires Microsoft 365 Copilot + commercial work/school account + active SharePoint/OneDrive licensing; Windows Capture is Office Insiders Beta.
  • MCP agents include Catalyst as well as Word, Excel, PowerPoint and Outlook.
  • Agent 365 Block-mode real-time protection rules must be redefined at cutover.
  • Copilot Business promotions: Partner Center (updated 23 Jul) confirms standalone + Business Basic bundle through 31 Dec 2026. The generic Sep footnote is a different Copilot offer.
  • Every price must say USD and include term/billing/seat scope beside the number.

13. July tuition — mistakes to never repay

What cost time Root cause Permanent fix
Started from "32 slides / 15–17 features" Skill docs described June's curated intent, not Sush's July full-edition preference Default to full edition / all numbered sections unless Sush explicitly asks to curate
June reference said 33 while docs said 32 Disclaimer slide wasn't counted Count the actual reference PPTX before planning; formula = N + 15 for full edition
July cover/front matter still said June cover4, editor4, dashboard4, opener4, matrix4, whatchanged4, closing4 are hardcoded across 2 engine files Patch three surfaces before feature authoring: content module + premium4.py cover + premium4_slides.py specials
Special slides had invented annual stats Dashboard examples encouraged unsupported running totals Use only counts provable from the issue: numbered updates, roundups, actions, picks, status counts
Long model/pricing body disappeared behind Why-card Fixed body/Why anchors + copy too long; renderer clips silently If body approaches Why-card, shorten copy. Never move the anchors or shrink below 11pt
Right panels looked empty Raw screenshots had whitespace or two mismatched aspect ratios Crop to the UI card; create a vertical composite for related images; verify at 1280×720
Cursor/background clutter Screenshots captured transient cursor/toast background Make deck-only clean crops; never alter meaning, only empty surrounding pixels
Watermark setting unreadable Full-window image preserved too much dim context Crop to the exact control if the section is about one setting
Roadmap tags wrapped Tag column fixed at 1.35" vis_cards(..., tag_w=...); use 1.85" for detailed Preview/GA labels
Pricing dates looked contradictory Generic pricing footnote and SMB Partner Center offer were different scopes Resolve pricing by product + channel + eligibility, not date alone
Company-wide prompts sat in Agents Blog order and divider placement were treated as classification Agents closes after Sales; Prompt Gallery publishing starts Admin
One issue-wide editor pick wasn't enough Full edition needs navigation inside each large section Up to one meaningful pick per major content section; never badge filler
Demo/official image provenance was implicit Readers can mistake screenshots for universal tenant state Blog-wide screenshot note + per-slide screenshot note + final disclaimer
First image QA passed but mobile overflow remained Fixed max-width: Npx expanded the document on narrow screens For blog image caps: width:Npx;max-width:100%;height:auto;box-sizing:border-box
Local main was hundreds of commits behind Dirty/stale umbrella worktree polluted build and cache guards Deploy from a fresh clone of origin/main, overlay only referenced files, explicit-path commit
SEO workflow failed during July deploy Pre-existing ROI page missing OG/long description; strict scan is whole-blog Prove failure is baseline, keep July diff clean, verify Build/OG + live production + post-deploy smoke
Carousel copy contradicted its screenshot The extractor kept only paragraph one; the model-name disclosure lived in paragraph two Add a small explicit LEAD_OVERRIDES map and compare every rendered claim with the pixels
A correct scheduling screenshot looked expired Its visible May/June dates made a July feature appear stale Use a handwritten statement card when dates or rollout context weaken otherwise-correct proof
Carousel content was at risk of being authored twice June's generator used a manual UPDATES list Extract feature4 + feature_visual records from the final pack builder; manually maintain only image decisions, pull-quotes and rare copy overrides

Reference code preserved

The exact working July scripts are stored at:

~/.copilot/skills/whats-new-copilot-pack/scripts/reference_july_2026/

Contains: build_pack_july.py, deckbuild.py, premium.py, premium4.py, premium4_slides.py.

For August: copy this directory first. Do not start from the June worked example.


14. August fast path — target 90–120 minutes after blog/screenshots are locked

Gate 0 — don't start the deck early

The pack begins only when:

  • Blog copy is SME-clean and section order is final.
  • All numbered sections are known.
  • Screenshot set is final and has a Rule #8 audit.
  • Image source note (demo vs official) is decided.

Changing blog order after deck authoring creates double work in titles, anchors, page numbers, sections and carousel.

Step 1 — choose edition shape (2 minutes)

  • Default for Sush: full edition.
  • slide count = numbered features + 15.
  • Curated edition only if Sush explicitly says to trim.
  • Decide section boundaries before code:
  • Headlines/user features
  • Agents
  • Admin/governance/security
  • Horizon

Step 2 — copy latest reference (3 minutes)

Copy scripts/reference_july_2026/ to the session working folder.

Patch, in this order:

  1. premium4.py — issue/month + cover highlights.
  2. premium4_slides.py — editor, dashboard, what-changed, opener, matrix, closing.
  3. build_pack_<month>.py — all numbered features, roundups, divider placement, links.

Run python -m py_compile before the first build.

Step 3 — assets (10–15 minutes)

  • Parse image references from the final blog; convert only referenced webp files to PNG.
  • Copy m365-logo.png, grid-panel.png.
  • Generate a fresh month QR.
  • Create deck-only crops/composites where needed; do not modify blog originals.
  • Add the standard screenshot variance note through place_framed.

Step 4 — author without re-researching (35–50 minutes)

  • Deck mirrors the final blog.
  • One numbered blog section = one feature slide in full edition.
  • Feature copy:
  • 1–2 short body paragraphs
  • one Why-it-matters sentence
  • optional Do next
  • Microsoft source + blog deep-link
  • Text-only feature → designed visual, never a fake screenshot.
  • Admin classification is based on who acts, not on where the feature appeared in release notes.

Step 5 — first build + mechanical checks (10 minutes)

  • Fresh versioned filename.
  • Verify metadata, slide count, no sections, no stale month text.
  • COM-render every slide at 1280×720.
  • Generate contact sheets.

Step 6 — parallel QA (15–20 minutes)

Run together:

  1. Fresh-eyes visual agent over all rendered slides.
  2. SME/deck-to-blog research agent.

Fix deck divergence immediately. If the blog itself needs a fact update, update blog + deck together, then rerun blog gates.

Step 7 — one focused fix cycle (10–20 minutes)

Common fixes:

  • Shorten clipped body text.
  • Crop/compose weak right panels.
  • Widen roundup tag column.
  • Clarify pricing scope.
  • Re-render only affected slides.

Then run one final all-slide fresh-eyes pass.

Step 8 — deliver

  • Leave only the newest deck in Downloads; move intermediates to the session folder.
  • Blog deploy and pack sharing are separate decisions.
  • Never call blog LIVE until production URL + markers + images + mobile/desktop + smoke test are verified.

August kickoff line

Hey Atlas — build the August What's New pack. Read the final August blog, the monthly-pack playbook §14, and copy scripts/reference_july_2026 as the starting point. Full edition unless I say trim.


The carousel is a distribution layer, not a second editorial project. The blog owns the facts and order; the final pack builder already contains the concise title, status, lead and why-it-matters copy. Reuse that structure.

Gate 0 — recheck the official monthly roundup

Before carousel authoring, rerun the official-roundup discovery gate from §9.

  • If a new official roundup appeared after the blog/pack locked, disposition its atomic capabilities before publishing the carousel.
  • If it is still NOT_YET_PUBLISHED, record the recheck date and continue from the approved live blog + final pack.
  • Never let a late official source silently create blog/pack/carousel divergence.

Step 1 — extract, do not retype

Execute the final build_pack_<month>.py against recorder stubs:

  1. Stub deckbuild.new_deck, metadata and save calls.
  2. Record each premium4.feature4(...).
  3. Record each premium4_slides.feature_visual(...).
  4. Track the current divider title as the section.
  5. Ignore front matter, roundups, closing and disclaimer.
  6. Assert the extracted count equals the blog's numbered-section count.

This gives one record per numbered update:

section · title · status · filled/outline pill · first body paragraph · why

Efficiency win: July extracted all 31 cards directly from the final v9 pack builder. No second 31-item content module.

Step 2 — keep only three small manual maps

Map Purpose
IMAGE_BY_TITLE Pick one approved screenshot or None for a statement card
PULLS Short handwritten line for statement cards
LEAD_OVERRIDES Preserve a caveat/disclosure that the compact extractor would otherwise lose

Use an override when:

  • the screenshot's live UI label differs from the release-note name;
  • paragraph two contains a licensing, region, pricing or rollout caveat needed to interpret paragraph one;
  • the compact first paragraph becomes misleading without the omitted context.

Do not solve this by automatically adding every second paragraph. That makes most cards too dense.

Step 3 — screenshot decision gate

For every update, choose one:

  1. Screenshot card — the pixels directly prove the feature and remain current.
  2. Statement card — no clean image, the image is only adjacent/partial, or visible dates make it look stale.

Red flags:

  • UI label contradicts the card copy.
  • A schedule, expiry or rollout date predates the issue and dominates the image.
  • The relevant control is unreadably small even after a truthful crop.
  • The screenshot needs a long disclaimer to explain why it is only partial.

Under-representation is better than misleading proof.

Step 4 — build format

  • 1080×1350 portrait card; render at 2× = 2160×2700.
  • Full edition = cover + every numbered update in blog order + closing.
  • Formula: carousel pages = numbered updates + 2.
  • Reuse scripts/carousel_build.py for CSS/HTML and carousel_render.mjs for Playwright rendering.
  • Deliver under a fresh versioned PDF name. If QA finds a blocker, create v2; do not overwrite a possibly open file.

Step 5 — required QA stack

Run all five:

  1. Rule #8 source-image audit — open every used screenshot and write literal pixel observations.
  2. Contact sheets — four cards per sheet for rhythm, hierarchy and stale-month review.
  3. Fresh-eyes visual agent — every card; prioritize contradictory evidence, dated screenshots, clipping and weak crops.
  4. DOM QA — assert exact card count, sequential 01 / NN numbering, 1080×1350 card boxes, no broken images, no previous-month text and no meaningful overflow.
  5. PDF QA — page count equals rendered-card count, every PNG is 2160×2700, file remains below LinkedIn's document limit.

If a reviewer finds a blocker, prove it directly from the card/source pixels, fix it, and ask the same reviewer to recheck only the affected cards.

Step 6 — short LinkedIn caption

Apply the Voice Rule first: ask Sush which honest angle he wants.

Proven short shape:

  1. Service hook: "Every month I read every release note, roadmap update and official announcement — so you don't have to."
  2. State the update count.
  3. Give three useful picks as short bullets.
  4. Say the full month is in the carousel.
  5. Put the blog URL near the end.
  6. Use two relevant hashtags.

Upload the PDF as a LinkedIn document, not 33 separate images. Atlas drafts only; Sush posts.

Hey Atlas — build the <Month> What's New LinkedIn carousel. Read the final blog, use the final pack builder as the structured source, and follow the monthly-pack playbook §15. Full edition unless I say trim.


16. August 2026 issue facts

Thing Value
Blog /blog/microsoft-365-copilot-august-2026-updates/ — 59 numbered sections, 60 images
Pack Issue 08 · 73 slides · 61 distinct screenshots in 63 placements · 135 hyperlinks
Status split 39 GA · 7 rolling out · 13 preview — computed at build time, never typed
Dividers 7, 32, 46, 56, 70 · editor's picks §{1, 28, 38, 47} → slides
New this issue Ko-fi PDF archive card + second QR on the closing slide; same link added to all 7 monthly posts
Formula deviation 73, not the N+15 = 74 the playbook predicts. August numbers everything, so there was no unnumbered content to roll up into a roundup slide. An honest deviation — flag it, do not invent a slide to hit the formula.

17. August tuition — mistakes to never repay

Sush distributed July's pack as a PDF and a reader reported every hyperlink was dead. It was not user error.

Path Result
File → Save As → PDF (COM SaveCopyAs(path, 32)) 0 links. Every hyperlink flattened to plain text.
File → Export → Create PDF/XPS (COM ExportAsFixedFormat(path, 2, ...)) 88 links preserved.

Reproduced on July's own deck: the PPTX carried 86 external link relationships, Sush's distributed PDF had 0 URI annotations across 46 pages, and a SaveCopyAs reproduction landed within 679 bytes of his file. Proof, not theory.

Permanent rule: always export with ExportAsFixedFormat, then verify the count before handing it over. Count links in the source with TargetMode="External" across ppt/slides/_rels/*, count them in the output with PyMuPDF page.get_links(), and compare. A PDF that has not been link-counted has not been checked.

The recurring bug class this month: string matches that are too narrow or too broad

Five separate instances in one build. Every one silently succeeded and produced wrong output.

Match Why it failed
<p><img Missed 5 of 60 images wrapped in a styled <p>
<p style=…><em> Assumed every caption had an <em> wrapper
id="anchor" Hugo minifies attributes unquoted — probe id=anchor
\*\*bold\*\* only Left literal *italics* asterisks rendering in 9 sections
An old_str that was a prefix of the real line Appended a stray fragment onto the new line

Permanent fix: after any edit whose old_str could be a prefix of a longer line, re-read the file. The tool reports success either way. When extracting, assert the expected count (60 images) and fail loudly on a miss.

Never hardcode a number you can derive

Slide 3's GA/rolling-out/preview counts were typed by hand and were wrong. Replaced with a status_counts() helper that buckets from STATUS at build time. Any count on a summary slide must be computed from the data.

⚠️ __main__ trap: the content module runs as a script, so it registers as __main__. A plain from august_content import STATUS inside the slides module re-executes the whole build. Read sys.modules["__main__"].STATUS first, then fall back to a real import.

A tall composite must be split before it enters the narrow right panel

§1's blog image is a vertical composite (themed-Excel result stacked over the @-skill-picker callout). Stacked into the deck's right panel it rendered too small to read — the month's best feature, illegible.

split_s01.py cuts it at the white gutter and place_framed() lays the halves side by side — the engine already maps n == 2 to a 1×2 grid, so splitting the file was the entire fix. Gains: slide 8 3.48" wide (+23%), slide 2 3.20" (+34%). Equal half-cells beat aspect-proportional widths, which would have made the tall image smaller because the wide callout hogs the width.

🔴 Splitting an image forces a caption rewrite. "Top:/Bottom:" becomes "Left:/Right:". Miss it and the deck ships a caption that describes a layout the reader is not looking at. This is the one place the deck may legitimately differ from the blog — because the picture differs.

Verify an SME agent's claims against first-party evidence

The first SME agent was wrong on 2 of its 3 "must fix" items — both times reasoning from secondary release-note wording while the blog's own screenshot showed the opposite (a "File → Info" path it said did not exist; a Power Automate action it said was not preview when the action is literally named "(preview)").

Absence of wording in a release note is not evidence of absence in the product. Brief the agent to mark such findings LOW confidence, and verify every decisive claim yourself before accepting it.

Rule #8 artifact defects are themselves defects

The v9 audit doc listed slide 2 under "non-image slides" when it has always carried the §1 imagery — an unaudited image inside the document whose whole job is to prove every image was audited. Rebuild the register from the built file (python-pptx, chrome excluded by SHA-1 frequency), not from the section list.

Verifying QR codes with no decoder available

No opencv/pyzbar wheel exists for Python 3.12 on Windows ARM64. Rather than skip the check: regenerate the QR from the intended URL with identical parameters and compare SHA-1 against the embedded blob. Byte-identical output proves the embedded code encodes that exact URL. Both August QRs verified this way.

Editing a published monthly post invalidates its QA receipt

The receipt is keyed to a content hash, and the pre-push hook blocks on a stale one. After any edit to a published issue — even adding a single link — re-run:

python scripts/monthly-blog-qa.py audit --post content/blog/<post>.md --write-receipt

and commit the receipt with the post. Only posts that already have a receipt need one; older issues are covered by legacy-baseline.json.

August kickoff line (for September)

Hey Atlas — build the September What's New pack. Read the final blog, copy the August reference builder, full edition, and follow the monthly-pack playbook §14 + §17. Export the PDF with ExportAsFixedFormat and give me the link count.

17.4 SME findings are about the deck, not the blog — triage against the blog first

The August second-pass SME agent returned 5 MUST FIX / 5 SHOULD FIX. It was fed the deck text only, so it flagged the deck's gaps as factual errors. Checking each against the blog changed almost every verdict:

  • The blog already carried the qualifier in 8 of them. e.g. blog §26 says *For: … · Frontier · Rolled out June 2026*; §28 says Microsoft 365 Copilot licence required; §45 says "still lists this as Local browser use (Frontier) … Plan on Frontier availability". The deck's condensed body had truncated them away. Fix = restore blog fidelity, not adopt the agent's suggested rewrite.
  • 2 findings were the agent being wrong. Slide 65's "Withdrawn 4 Aug" pill and its "gone from Microsoft's own guidance" caption are verbatim blog text (blog L1139, L1148). Deck matches blog -> Sush's editorial call, flag it, never silently change it.

Order of operations: blog first, Microsoft second. Only escalate to Sush when the deck matches the blog and the blog disagrees with Microsoft.

17.5 Audit the class, not the instances the agent happened to notice

Rather than patching the 5 sections the SME named, audit_status_fidelity.py diffed all 59 blog *For: …* lines against the built deck's per-slide text. It found 9, i.e. the agent had missed 5. Worth keeping and re-running each month.

Two honest caveats — the same over-broad string-match class that has now bitten seven times: - False positives: the token admin matched "Microsoft 365 admin center" (an audience, not a qualifier); Rolling out missed that the pill July · Markdown August already said it. - A false negative: slide 34 did contain "Frontier" — but only inside the caption describing Sush's own tenant, not as an availability qualifier. The naive substring check passed a slide that was genuinely wrong. Match the field, not the whole slide.

17.6 est_body11() is not additive — measure the assembled body

First attempt reserved the qualifier's height by measuring it alone and subtracting from avail. Slide 60 still overflowed (1.44 vs 1.35) because space_before and line-wrap rounding mean height(a) + height(b) != height([a, b]). The fix is to attach the qualifier to every candidate and measure the whole thing:

def assemble(main): return main + [qpara] if qpara else main
def fits(b): return P4.est_body11(b, P4.LW) <= avail

This also makes the qualifier non-negotiable: the prose shrinks around it instead of it being trimmed. Result: "all 59 feature slides fit".

17.7 Status changes recompute the dashboard for free

Reclassifying §26 and §45 from GA to Frontier-preview moved slide 3 from 39/7/13 to 37/7/15 with no edit, because status_counts() derives it. Never hardcode those numbers (see §17.1).


18. September 2026 issue facts

Thing Value
Blog /blog/microsoft-365-copilot-september-2026-updates/ — 94 numbered sections, 136 images
Pack Issue 09 · 110 slides · 136 images · 205 hyperlinks · 53.4 MB pptx / 12.9 MB pdf
Carousel 20 cards (cover + 18 picks + closing), 6.5 MB, 2160×2700
Picks §{1, 3, 12, 13, 19, 22, 24, 26, 33, 47, 51, 59, 64, 65, 86, 90, 91, 92}

19. 🔴 Image resolution — the August complaint, and the fix

Sush's complaint after the August pack: readers could not read the text in the images. It was real and measurable, not a matter of taste.

July August (complained about) September (fixed)
Pages with an image 45 72 109
Hero width min / median / max 1254 / 1254 / 1867 802 / 802 / 975 1254 / 1439 / 1916
Heroes ≥900px (readable) 45 (100%) 1 (1%) 109 (100%)
Live hyperlinks 0 135 205
File size 3.6 MB 2.3 MB 12.9 MB

August downsampled all but one page to exactly 802px — the signature of a screen-intent export. July had full-size images but zero live hyperlinks, because SaveCopyAs flattens them.

The single fix for both:

prs.ExportAsFixedFormat(pdf_path, 2, Intent=2)   # 2 = print intent, keeps links

🔴 Retire the "PDF/PPTX size ratio ≥ 35%" heuristic — it is unreliable in both directions. September passes every real gate at 24%, while July passed the ratio check carrying 143 unreadable images. Gate on the width distribution and the hyperlink count instead, which is what measure_heroes.py reports. A single number that can be satisfied by a bad deck is not a gate.


20. Traps found in September (each cost real time)

Cards render at deviceScaleFactor: 2. An image is therefore upscaled whenever its CSS width exceeds native / 2 — so a QA that measures CSS pixels reports a comfortable x0.50 for an image actually being blown up 1.35×. That is exactly August's blur, passing a green check.

Fix it at the source rather than by hand-picking images — make upscaling impossible:

f'<img src="{src}" style="max-width:min(100%,{native_w // 2}px)">'

An image now renders slightly smaller rather than soft. After this, max render scale across all 18 shots was exactly 1.00, with 0 shots under 1000px native.

20.2 🔴 Clip markdown BEFORE converting it to HTML

clip(md(text)) can sever an <em>…</em> pair. In a single-document carousel the orphan opening tag tips every subsequent card into italics — the defect appears on cards you never edited.

compact() must clip the raw markdown at a sentence boundary, then convert, then assert html.count("<em>") == html.count("</em>").

20.3 The vertical budget is zero-sum — every character costs screenshot

.card is a fixed 1080×1350 with overflow:hidden; .shot is the only flex:1 1 auto row. So copy length is taken directly out of the image, and when text overruns, nothing visibly breaks — content is silently clipped off the bottom. A healthy card measures bottom 1222/1350. Measured floor from August's shipped cards: shot boxes 254–341px. Budgets that hold: LEAD_CHARS, WHY_CHARS = 165, 215.

A lead that ends in ... means the source sentence was longer than the budget. That needs a hand-written LEAD_OVERRIDE, not a machine truncation — so assert against it.

20.4 Aspect ratio, not resolution, decides how big a shot looks

With .shot capped near 300px tall, any image with aspect < ~2.9 is height-limited: rendered width ≈ min(884, (boxH - 28) × w/h). A 2240×1200 image fills 62% of the card; a 1679×1567 one only 35%. Pick by aspect, then verify resolution — not the other way round.

When forced to choose, prefer sharp-and-small over big-and-soft (§51 keeps a tall portrait rendering at ~21% width rather than a 445px crop that would upscale 1.28×).

20.5 Asset filenames carry LEGACY draft numbers

verify_mapping.py produced a confident mapping with a constant +33 offset, because image filenames retain numbers from an earlier draft ordering. Never infer section ownership from a number in a filename — map through the blog's parsed structure.

20.6 The deck's TITLE map is not the blog's title

read_pills.py matched only 23 of 94 because it joined on blog titles; the deck carries its own shortened TITLE. Join on the section number.

20.7 Geometry QA cannot see editorial defects

The DOM QA reported 0 issues on a build where cards 17 and 18 both read EDITOR'S PICK · ADMIN and cards 3 and 4 both read IN COPILOT CHAT. Kickers are now asserted: no two consecutive cards may share one (check_kickers.py).

⚠️ That checker's first version was itself off by one — the cover card carries a kicker too, so kickers[0] is card 1. An off-by-one here misattributes every duplicate to the wrong pair.

ko-fi.com sits behind a Cloudflare bot challenge returning 403 "Just a moment…" for every path. A browser-based probe therefore reported the slide-pack CTA as BROKEN — a false alarm one step from editing a perfectly good URL.

The negative control settled it: two deliberately bogus Ko-fi paths returned the byte-identical challenge, proving the method non-probative. Correct outcome is UNKNOWN, flagged for a human click — never a verdict in either direction.

The pattern of this build: six verification scripts produced confident wrong answers, and every one was caught by a positive or negative control, never by inspection. Budget for the control, not for re-reading the script.

20.9 Environment quirks that bite every month

  • <month>_content.py builds the deck on import. Read it statically (ast.literal_eval), or exec only the header above prs, layout = D.new_deck().
  • PowerShell has no heredoc, and nested quotes inside python -c / node -e fail. Write a temp file.
  • Set $env:PYTHONIOENCODING='utf-8' — cp1252 chokes on →.
  • Deliver to Desktop\Whats New\ as Whats New in M365 Copilot - <Month> <Year>.pptx|.pdf and AGTC-Whats-New-<Month>-<Year>-LinkedIn-Carousel.pdf.

20.10 Sush's standing instruction for this pack

QA the blog while building the pack. Extraction reads every section closely and reliably surfaces defects the blog's own review missed — treat those findings as in scope, not a detour.

20.11 🔴 A slide holds 4 section images — and the cap must be fail-closed

<month>_content.py had a single image-consumption point and applied no cap, so every image a blog section carried became a slide picture. September §33 had 7 and §35 had 5; both ran off the bottom of the canvas and shipped that way in the first delivery.

Measured capacity across the 110-slide deck: 1 slide with 0 pictures · 12 with 1 · 71 with 2 · 15 with 3 · 8 with 4 · 3 with 5. Picture counts run +1 vs blog images (one chrome shape), so 4 section images is the proven ceiling.

The fix is MAX_SLIDE_IMAGES = 4 plus an IMAGE_PICK map naming which images survive — and it is fail-closed in both directions, which is the part that matters:

  • a stem in IMAGE_PICK that matches no real image → abort the build
  • a section over the cap with no IMAGE_PICK entry → abort the build

Without the second assert a future month silently reintroduces the overflow. control_slide_images.py proves both traps actually fire (5/5).

Pick by what carries the section, not by what is first: §33 dropped a duplicate toggle shot and a before/after output demo; §35 dropped a text-panel reply that reads poorly at slide size.

20.12 🔴 The bounds gate measures shape boxes — it is blind to text overflow

A build-time gate now walks every shape on every slide before prs.save() and raises SystemExit on anything outside the canvas. Positive-controlled against the real delivered pre-fix deck, it caught 10 shapes on exactly slides 42 and 44 (1.79in, 1.75in, 0.97in, 0.93in on 42; 0.47in, 0.43in on 44) and 0 on the rebuilt deck.

Then it passed a slide that was visibly broken. A 178-character postscript added to slide 2 sat in a textbox at y=6.80 h=0.55 — comfortably inside a 7.5in slide — while the text wrapped to a fourth line and ran off the bottom edge. The gate is structurally incapable of seeing this: a box can be in-bounds while its contents are not.

Two consequences, both permanent:

  • For text, render the slide and look at it. No gate substitutes for Rule #8.
  • Where copy is generated near a boundary, add an explicit character budget with the reason in the message — slide 2 now asserts len(ps) <= 165 because 165 is where it wraps to a 4th line.

Left column LW ≈ 4.03in; at 10pt that is ~58 chars/line.

20.13 🔴 A control whose positive case is a live artifact can only run once

control_bounds_gate.py first pointed its positive control at the delivered deck — which worked exactly until the fix shipped, at which point the control began reporting FAIL forever, because the thing it needed to be broken had been repaired.

A control must manufacture its own broken input. It now synthesises a deck containing a shape 1.79in off-canvas, plus a clean deck, and checks the real deck only if present (SKIP otherwise). 3/3, repeatable every month. If your control cannot run twice, it is a one-shot test, not a control.

20.14 🔴 export_pdf.py prints "GATE PASSED" while most images are downsampled

Its pass conditions (L179–204) examine only the single widest image in the entire PDF: pdf_max >= 1280, pdf_max >= src_max * 0.6, good > 0, links > 0. It says nothing whatsoever about the other 592. September printed GATE PASSED alongside 409 of 593 images under 700px.

Both older heuristics in the skill were also wrong, and September disproved each:

Retired heuristic What September measured Why it fails
PDF should be 40–60% of PPTX 51.1 MB → 12.7 MB (25%), perfect quality Ratio tracks how often screenshots repeat, not compression
Count every embedded image < 900px 409/593 under 700px, perfect quality Counts the repeated logo/grid chrome

The probative measure is the widest image per page — that is the screenshot. measure_heroes.py: September 109/109 pages ≥900px, min 1254, median 1439, max 1916; August's broken export 1/72 pages (1%), median 802. Gate on this and on the hyperlink count (205 links preserved via ExportAsFixedFormat(..., Intent=2); SaveCopyAs flattens them).

20.15 Promote the month's work back into the skill — and prove the copy runs

The skill's top-level premium4*.py / deckbuild.py were still the June files months later; July's improvements lived only in reference_july_2026/, and August's were never promoted at all. Each month silently started from an older engine than the one that had just been proven.

At ship, create reference_<month>_<year>/ and copy the engine, the content module, its data files, the controls and the measure scripts — then run the controls from inside that folder. September's first promotion copied all 10 scripts with 0 hash mismatches and still could not execute: blog_sections.json and meta.json had been left behind. A copy that does not run is not a promotion.

20.16 Editing the blog after publish re-opens the QA gate — by design

Changing the post invalidates its content hash, so pre-push refuses the push: PUSH BLOCKED - a published monthly issue has no valid QA receipt. This is correct behaviour, not an obstacle. Re-run and commit the receipt alongside the content change:

python scripts/monthly-blog-qa.py audit --post content/blog/<post>.md --write-receipt

September's re-run: 94 sections, 136/136 images observed, 0 outstanding, state: PASS.

20.17 The blog markdown is CRLF while the repo blob is LF — and that is fine

git config core.autocrlf is true, so git add normalises on the way in. A genuine 3-line edit shows the correct 3 1 in git diff --cached --numstat. 🔴 Forcing -c core.autocrlf=false for the comparison alone reports 2515 2513 — a whole-file diff that looks like catastrophe and is purely an artifact of mismatched flags between the add and the diff. Measure with the plain command, or with --ignore-cr-at-eol; both agreed at 3 1.

20.18 The PDF cannot be made smaller — six measured dead ends

September's 12.7 MB / 110-page PDF had to reach 2,000+ inboxes, so the obvious ask was "shrink it". It cannot be shrunk. 11.29 of 12.72 MB is images, already split 5.65 MB JPEG (lossy already) and 5.64 MB well-packed PNG. Every lever was measured:

Method Result
Lossless source-PNG re-optimisation (140 assets) 98% — a 2% saving that never reaches the PDF
Palette quantization — q256 / q256+dither / octree Rejected — only 4/140 pass a MAXD=24, MEAND=0.5 gate
Byte-identical image dedup inside the PDF 0 MB — PowerPoint already shares xrefs (593 placements → 372 images)
Lossless PDF rewrite (garbage=4, clean, deflate_images) +1.8% — it grows
JPEG q=88 4:4:4 over all 272 PNGs 11.14 MB — 12% for visible text damage
Downsampling to 1400 / 1200 / 1100 / 1000 px No saving; see the instrument trap below

🔴 Quantization corrupts Microsoft brand colour, not text. The numbers looked excellent — files to 34–47%, mean diff 0.30–0.70/255 — because quantization shifts colour and never moves pixels, so text stays pixel-sharp. The damage lands on small gradient icons: the Planner Agent icon went purple → blue with red speckles. It was visible only because the Rule #8 crop deliberately located the worst window instead of a flattering one. Max diff reached 122–179 there while the image-wide mean stayed under 0.7 — so a low mean diff is not evidence of fidelity.

🔴 Instrument trap — a downsample probe that re-encodes everything as PNG reports the file getting BIGGER (12.7 → 21–29 MB), because re-encoding the already-JPEG half as PNG inflates it. The same probe reported "305/372 images under 900px", which is equally misleading: most of those are small chrome and icons. The resolution gate is measure_heroes.py — the widest image per page — never a count across every image object.

The real answer is architectural, not compressive: the pack is already published on Ko-fi (layouts/shortcodes/pack-download.html is the single source of truth for that URL), so send the link rather than the file. Sush chose to attach and batch for September; the number that matters then is that email base64-encodes attachments, so 12.7 MB arrives as ~17 MB on the wire. That clears Exchange Online's 25 MB default but bounces on any gateway capping at 10–15 MB.

All 205 annotations are /URI actions carrying exactly /S, /Type, /URI. The PDF spec has no /NewWindow entry for /URI — that flag exists only for /GoToR, /Launch and embedded go-to actions. Tab behaviour belongs entirely to the reader's viewer. The one hack that sometimes works, embedded app.launchURL(url, true), is ignored by every browser PDF viewer and turns the file into a JS-bearing PDF — a known malware vector, and a real spam-filter risk on a 2,000-recipient send. Don't. The web CTA already opens in a new tab (target="_blank" in pack-download.html).

Verifying that the links resolve is the useful check instead (§20.8). September: 157 unique URLs across 205 annotations, 157/157 → 200. 🔴 One reported 429 — aka.ms rate-limits a concurrent checker — so a lone serial retry is mandatory before calling any link broken. It returned 200 on the first retry, resolving to the correct Dynamics 365 roadmap post.

20.20 The internal and external emails are two different design systems, not one restyled

Discovered 21 Sep 2026, after Sush rejected the September internal: "it doesn't remotely look like the internal one we send last month — internal is quite different to external — look is different, the way we format is different." He was right. The September internal had been reconstructed from a text render of August's send, so the visual property was never checked, and it came out in the external's ivory/brass newsletter styling.

Measured from the two real August sends:

Internal (field) External (customers)
Font Aptos (71 occurrences) — honours the standing Aptos email rule Segoe UI newsletter
Structure 0 tables, 41 divs, 8 ul, 24 li — a flat memo 67 nested tables — a card grid
Palette Fluent blue #0078D4, greys, amber callouts ivory / brass / navy
Width none — flows to the client's width locked max-width:640px
Register short blunt field-facing bullets long cards with a "why it matters"

🔴 Never restyle one into the other. Build the internal from _aug_internal.html's own tokens. September's rebuild lives in build_internal_fluent.py; the external generator (build_emails_september.py → build_external()) is correct and unchanged.

🔴 A visual property cannot be judged from a text render (Rule #8). Render both months to PNG with _shot.mjs and look at them side by side before claiming a design matches.

August internal tokens, verbatim — font stack Aptos,"Aptos Display",Aptos_EmbeddedFont, Aptos_MSFontService,"Segoe UI",Calibri,Helvetica,Arial,sans-serif; ink rgb(32,31,30); blue rgb(0,120,212); dark blue rgb(0,90,158); secondary rgb(96,94,92); dateline rgb(138,136,134); grey box rgb(243,242,241); hairline rgb(225,223,221); blue tint rgb(239,246,252); amber rgb(244,200,66) on rgb(255,244,206); brass rgb(166,130,76). Body 14px, section headings 15px, datelines 12.5px, header bar 16px. "✓ I checked this myself" is BRASS, not green — there is no green anywhere in the file; the extracted palette settled it after my eye misread the screenshot.

class="elementToProof", id="OWA…" and class="OWAAutoLink" are Outlook-added artifacts in the saved send. Do not reproduce them in the generator.

20.21 🔴 The WorkIQ MCP server returns its payload in structuredContent, leaving content EMPTY

A JSON-RPC tools/call against workiq.cmd mcp replies with {"content": [], "structuredContent": {...}, "isError": false}. Every MCP client that reads only content — the conventional field — gets "" and reads a successful call as a failure.

Measured 21 Sep 2026: _make_drafts.py printed [?] no id returned and exited 1 after correctly creating both Outlook drafts, and a later fetch wrote a zero-byte file while returning HTTP 200. Both were the same bug in my reader, not in the server.

sc = res.get("structuredContent")
if sc is not None:
    return json.dumps(sc), bool(res.get("isError"))
return "".join(c.get("text", "") for c in res.get("content", [])), bool(res.get("isError"))

Fixed in _mcp.py. Same instrument-failure class as the -SimpleMatch and $LASTEXITCODE-in-a-pipeline traps: the tool was right and the measuring device was wrong. Never trust a write tool's own exit code — read the entity back (Rule #14a). _prove_drafts.py does exactly that: it re-fetches both drafts and asserts design (0 tables internal / 87 external), font, link count against the local file, isDraft, zero BCC, zero bare URLs and zero Word cruft.

Why the MCP stdio route exists at all: workiq.cmd create -b "<html>" cannot carry these bodies, because cmd.exe caps command-line arguments near 8 KB and the drafts are 35 KB and 81 KB. Spawning workiq.cmd mcp and speaking JSON-RPC over stdio moves the payload disk → API with no shell in between. POST /me/messages creates a draft and does not send, so the route is Rule #2 compliant by construction.

20.22 🔴 Hugo's minifier strips attribute quotes — any id="…" regex silently returns zero

The published page renders <h3 id=1-gpt-6-astra-arrived-in-cowork-and-copilot-studio> with no quotes. A harvester written as id="([^"]+)" matched 0 of 139 ids on the September blog, which the QA reported as "26 broken anchors" — a total-failure verdict on a page where every single anchor was fine.

This is the second time this minifier has faked a defect; the constitution records 28 "broken" alt attributes masked the same way in May 2026. Accept all three forms:

re.findall(r'id=(?:"([^"]+)"|\'([^\']+)\'|([^\s">]+))', html)

🔴 Add a plausibility guard to every harvester. A count of 0 — or implausibly low — must sys.exit, not report a finding. _qa_emails.py now aborts if it harvests fewer than 50 ids. Under Rule #15 this is the difference between iterating through the wall and captioning it: the first instinct was to start "fixing" 26 anchors that were never broken.