What's New in M365 Copilot — Monthly Pack Playbook¶
Deep playbook for the
whats-new-copilot-packskill (~/.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
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)¶
- Title overflow / title-touching-body is the recurring bug CLASS. Root cause (found 25 Jun):
estimate_title_linesmeasured at a 24pt-calibrated font, but titles render at 27pt (features) / 28pt (dividers). Long titles were under-counted as 1 line →body_yplaced 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. - PowerPoint sections leak from the template. "Default Section" / "Microsoft 365 Copilot" / "ADMIN" showed in the slide pane.
deckbuild.strip_sections()removes thep14:sectionLstext innew_deck(). - Stale metadata. The starting template carried a previous author in
dc:creator, revision 96, and anapp.xmlclaiming 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. - 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-Itemto an open file fails ("being used by another process") → deliver under a fresh versioned name (...PREMIUM v2/v3/v4.pptx). - Multiple images per feature →
place_framedlays 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). - 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.
- Chrome vs month assets.
m365-logo.png+grid-panel.pngare reusable (skillassets/chrome/); the QR is per-month (encodes that month's blog URL — regenerate); screenshots are per-month. All live together in the month'sWNP_ASSETSfolder at build time.
6. QA discipline (paid-quality, customer-facing surface)¶
- Vision-QA every screenshot (Rule #8). Open each with the
viewtool, 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
researchagent). 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
.mdside-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-roadmapMCP (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) andgeneralAvailabilityDate(what ships this month) — they return different sets. 🔴 Readmrc-roadmap-mcp-playbook.mdfirst: the results array isitemsnotvalue, livetools/listbeats 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
Microsoft365CopilotBlogboard — 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 |
Discovery — current month + previous two months¶
Use all three methods; one empty search is not proof:
- Exact web search:
site:techcommunity.microsoft.com/blog/microsoft365copilotblog "What's New in Microsoft 365 Copilot" "<Month Year>". - Inspect the official Microsoft 365 Copilot Blog board.
- 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, validog_glyph, OG image exists on disk —npm run build:og:blogif missing); Hugo viapwsh scripts\hugo-safe.ps1(never barehugo). Generate the OG image. - SME fact-check = background
researchagent 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) — theviewtool 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.webpin<session>/files/lab-shots/clean-backups/) before overwriting, so a box can be repositioned. - Verify after (Rule #8):
viewthe 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-256D2797FAD...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 LinkedIn carousel¶
- 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_PUBLISHEDat 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:
premium4.py— issue/month + cover highlights.premium4_slides.py— editor, dashboard, what-changed, opener, matrix, closing.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:
- Fresh-eyes visual agent over all rendered slides.
- 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.
15. LinkedIn carousel fast path — added 31 Jul 2026¶
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:
- Stub
deckbuild.new_deck, metadata and save calls. - Record each
premium4.feature4(...). - Record each
premium4_slides.feature_visual(...). - Track the current divider title as the section.
- Ignore front matter, roundups, closing and disclaimer.
- 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:
- Screenshot card — the pixels directly prove the feature and remain current.
- 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.pyfor CSS/HTML andcarousel_render.mjsfor 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:
- Rule #8 source-image audit — open every used screenshot and write literal pixel observations.
- Contact sheets — four cards per sheet for rhythm, hierarchy and stale-month review.
- Fresh-eyes visual agent — every card; prioritize contradictory evidence, dated screenshots, clipping and weak crops.
- DOM QA — assert exact card count, sequential
01 / NNnumbering, 1080×1350 card boxes, no broken images, no previous-month text and no meaningful overflow. - 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:
- Service hook: "Every month I read every release note, roadmap update and official announcement — so you don't have to."
- State the update count.
- Give three useful picks as short bullets.
- Say the full month is in the carousel.
- Put the blog URL near the end.
- Use two relevant hashtags.
Upload the PDF as a LinkedIn document, not 33 separate images. Atlas drafts only; Sush posts.
Next-carousel kickoff line¶
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¶
🔴 The big one: how you make the PDF decides whether any link works¶
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:
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 saysMicrosoft 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:
🔴 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)¶
20.1 🔴 The device-pixel upscale trap — the carousel version of the same complaint¶
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:
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.
20.8 🔴 A link checker that cannot fail is not a link checker¶
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.pybuilds the deck on import. Read it statically (ast.literal_eval), or exec only the header aboveprs, layout = D.new_deck().- PowerShell has no heredoc, and nested quotes inside
python -c/node -efail. Write a temp file. - Set
$env:PYTHONIOENCODING='utf-8'— cp1252 chokes on→. - Deliver to
Desktop\Whats New\asWhats New in M365 Copilot - <Month> <Year>.pptx|.pdfandAGTC-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_PICKthat matches no real image → abort the build - a section over the cap with no
IMAGE_PICKentry → 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) <= 165because 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:
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.
20.19 PDF links cannot be forced to open in a new tab¶
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:
🔴 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.