Skip to content

Copilot Monthly Blog — Screenshots & Vision-QA Playbook

Read this BEFORE capturing/placing screenshots for any monthly "What's New in M365 Copilot" recap blog. Distilled from the July 2026 recap (a ~40-image, 31-section session, 23 Jul 2026). Companion to whats-new-copilot-pack-playbook.md (the PPTX pack) and deployment-playbook.md. Sibling deliverable: the recap blog post (this doc) → then the pack + LinkedIn carousel are built from the same shots.


0. The workflow that works (Sush's preferred rhythm)

Go section-by-section in blog order (1→N), one feature at a time: 1. Atlas states what the feature is + what's changing (so Sush confirms understanding). 2. Sush captures a new shot OR Atlas reviews the existing one. 3. Atlas fixes/tweaks the notes if reality differs from the draft text. 4. Place → annotate → vision-QA → validate → move on.

Track progress in a SQL table (blog_sections: num, title, img_status, existing_imgs, review_verdict, notes) — gives instant "X of N done" visibility and a clean handoff. Worked great.

Deliver in small confirmable steps. Sush drops shots as he captures; grab the newest Downloads\*.png after a timestamp, don't re-scan everything.


1. Capture & verification (Rule #8 — never skip)

  • 🔴 Filenames LIE. Greenshot names files with the stale/active window title (e.g. ...-Notepad.png for a Copilot shot). ALWAYS view the actual pixels; never judge by filename. This bit us repeatedly — it's the #1 rule.
  • After placing every webp, view it and write a one-line pixel observation + verdict (✅ MATCH / ⚠️ PARTIAL / ❌ MISMATCH) to the session's image-audit-<date>.md. The written artifact is the compliance proof, not a verbal "I checked."
  • Large webps (>~1500px wide) fail the view tool ("Failed to view"). Render a downscaled PNG first: python -c "from PIL import Image; im=Image.open(r'<src>').convert('RGB'); w,h=im.size; im=(im.resize((1200,int(h*1200/w))) if w>1200 else im); im.save(r'$env:TEMP\_qa.png')" → view _qa.png.
  • Find newest captures fast: Get-ChildItem Downloads\*.png | Where LastWriteTime -gt <ts> | Sort LastWriteTime. Exclude your own temp files (_annot*, _comp*, _zoom*, _inspect*).

1a. 🔴 Alt text is verified against the image as published, never against the source it was cropped from

The September 2026 issue shipped four broken alt texts, and three of them were one defect class caught in all three official-*--focus images — a 100% hit rate once looked for. Each had been written and "verified" against Microsoft's full screenshot, then published as a tight crop. The alt text therefore announced interface the reader cannot reach. For a sighted reader a too-generous description is invisible; for a screen-reader user it is a description of a different image.

§ Alt text claimed Actually in the published crop
2 "Hi Elvia" greeting · left rail (New task, My tasks, Automations, Customize) · "Microsoft 365 Premium" account line Crop starts at the task box. Model list was accurate.
74 Copilot pane "on the right" · a prompt line · Done/Undo above "Describe what you would like to edit" Only the document section and the reply block beneath it.
88 "136 of 190 tasks completed" · four risk categories · a "Focus on" panel on the right Ends at the "At risk · 23% overdue (44 tasks)" line.

The workflow that caused it: crop for legibility → describe from the original you still have open. The description is richer, feels more helpful, and is wrong.

The check that catches it, and it is cheap: after cropping, re-open the published file and read the alt text against it line by line. If a noun in the alt text has no pixels in the frame, delete the noun. Do this after cropping, never before — and note that a crop made for annotation padding counts as a new image.

Two more traps from the same pass, both about reading rather than cropping:

  • 🔴 Count icons, not labels — repeated text hides adjacent controls. §14's Quick Create rail was read as six buttons; it is seven. Two adjacent buttons both read "Document" (one green Excel icon, one blue Word icon) and the repeated word made the pair scan as one. This did real damage: the body had built a whole claim on it — "seven options, one more than Microsoft's own screenshot" — which was false. The counts matched all along; only the label differed, because the tenant named the green one "Workbook". A miscount in an observation can silently become a factual claim in the prose.
  • 🔴 Do not describe an image from the live product. §83's alt text claimed a bordered list with envelope icons and a different topic per row. It is plain round bullets, no border, and only four subjects repeating down ~17 rows. That is what the UI looks like in general, not what this capture shows.

When you fix one, append a dated correction note to the observation block — what was claimed, what is actually there, and the lesson — rather than quietly editing. The September blocks carry > **Corrected 15 September 2026 …** notes; the image bytes are untouched so every hash still stands, and the reasoning survives for the next issue.

1b. 🔴 One image markup convention per issue — an undefined CSS class fails silently

The September 2026 issue carried two image conventions. 78 images were <p>-wrapped, framed, with the caption on an inline style. The other 51 were unwrapped, unframed, and captioned with class="img-caption"a class defined in no stylesheet anywhere. Those 51 captions rendered as full-size, full-opacity body paragraphs and the images lost their frame.

Why no gate caught it: an undefined CSS class raises no error, no warning, and no build failure. lint, audit and check-blog-html.mjs all passed with the defect present. It is only visible by looking at the page — or by counting conventions.

The cheap check, run once per issue before publish:

# every image should report the SAME shape. Two clusters = a defect.
Select-String -Path content\blog\<slug>.md -Pattern 'img-caption' | Measure-Object   # must be 0

Then compare the count of font-size:0.88rem;opacity:0.78 captions against the count of <img. They must match.

Proving a class is undefined requires the served stylesheets, not a repo grep. Grep finding nothing does not prove absence — the rule could live in a bundled or generated file. The sound test is to pull every <link rel=stylesheet> off the rendered page (8 files / ~307 KB here) plus the inline <style> blocks, and search those.

Fix forward, not sideways. The tempting fix is to define .img-caption in the shared stylesheet. Don't — that edits a global surface for one post's defect and drags in a cache_version bump. Rewrite the 51 to the convention the other 78 and 100% of the previous issue already use. 🔴 Preserve any deliberate narrow max-width (three phone screenshots used 480/380/400px); blanket-replacing with max-width:100% would blow them up to full width.

Root-cause class: a second convention drifts in mid-build and nothing compares the two. Expect it wherever markup is hand-written across a long authoring session. The guard is a count, not vigilance.

2. Annotation house style

🔴 Do not hand-roll ImageDraw boxes. Use scripts/annotate_screenshot.py. The locked, non-negotiable style lives in scripts/screenshot-annotator/README.md — read it, don't paraphrase it. Its one-line summary is the rule that matters: "measure, don't eyeball — ground truth is the render."

House style: opaque white rounded callout · red (207,38,38) ≈3–4px border · bold red Segoe UI text, no stroke · hard black drop shadow · font size ≈ final_width / 45 · thin red leader ending in a filled red dot · callouts sit in whitespace and you extend the canvas when there is none. Save .webp quality 92.

⚠️ The old (222,45,38) in earlier versions of this playbook was wrong and is corrected here. 🔴 The red is now declared in exactly one place: scripts/house_style.py (HOUSE_RED). Do not re-declare it, and do not quote a line number for it — both annotators import it, and monthly-blog-qa.test.py fails if either one hard-codes a red of its own. For two years annotate_lib.py drew (206,38,38) while annotate_screenshot.py drew (207,38,38); one unit is invisible to a person, permanent in the file, and was reported by nothing.

2.0 🔴 Alt text is part of the annotation — and it is enforced

An annotated screenshot's alt must say what the callout points at. This is checked on every push from the September 2026 issue onward (monthly-blog-qa.py annotations).

Rule Why
"A callout points to the Share response button" names the target — this is the accessible thing to do
"Black words on a white page, with a callout on Rewrite" "black" about the product is fine
"The selected cell is outlined in black" · "a black arrow opens the overflow" also product, also fine — the checker was deliberately narrowed so these pass
"The Share response item, circled in black" our callouts are #CF2626; describing them as black is simply wrong
"Screenshot of the Copilot pane" says nothing about the annotation
🚫 Do not write "a red box is shown" to satisfy the checker WCAG 1.1.1 wants equivalent information, not colour naming. The gate deliberately does not require a colour word

🔴 If you annotate an image, its alt must describe the annotation — what each callout says and what it points at. The house pattern, taken from a real September alt:

"…Red callouts added by me read: A priority I never asked for; Friday, resolved to a date; and And it said where it went. A red box outlines the matching part of the screen."

One September image shipped with 3 callouts and 2 boxes and an alt that mentioned none of them — it slipped through because the alt happened to say "a red exclamation mark". Describing the product is not describing the annotation.

Every image also needs a row in qa/monthly-copilot/<slug>.annotations.json — one of annotated, annotated_at_capture, created_asset, microsoft_artwork. The first carries hashes; the other three carry a written reason. Re-run the annotator or re-record the hash whenever you recolour or swap an image — the gate compares output_sha256 against the live bytes and will block the push. Full detail: copilot-monthly-blog-qa-playbook.md § The annotation gate.

2.1 Four image classes — only one gets annotated

Class Examples Annotate? Sidecar disposition
Lab screenshot — real product UI we captured lab-s66-*, most official-* screenshots ✅ yes annotated
Already annotated at capture UI that shipped with its own callout/highlight ❌ never double-annotate annotated_at_capture
Our own diagram created-* ❌ never — we drew it, it is already explanatory created_asset
Microsoft infographic / chart official-26-metrics-to-task-value, official-30-harness-benchmark ❌ never — do not draw on Microsoft's own artwork microsoft_artwork

🔴 The official- prefix does NOT mean "screenshot". Check what the image actually is. Also skip anything already carrying an annotation baked in at capture — give it a callout naming the existing box instead of double-annotating.

2.2 Running the tool

annotate_screenshot.py takes exactly one argument: a spec path. Wrong arg count prints the docstring and exits 0 with the images silently untouched — so always count the saved … W×H lines.

Spec file is {"images": [ … ]} with absolute src/dest. Keep the authoring spec as a plain list and convert:

items = json.load(open(f/"annot-bN.json", encoding="utf-8"))
for it in items:
    it["src"] = str(clean/it["src"]); it["dest"] = str(dest/Path(it["dest"]).name)
    assert Path(it["src"]).exists() and Path(it["dest"]).exists(), it["dest"]   # every target is an OVERWRITE
json.dump({"images": items}, open(f/"_bN-spec.json","w",encoding="utf-8"), indent=1)
Assert the destination folder too (len(list(dest.glob("*.webp"))) == <count>) — writing a whole batch into the wrong month is silent.

Per-image keys: scale, quality (not ops). Ops:

{"kind":"pad","left":460,"right":480,"fill":"white"}
{"kind":"box","bbox":[750,782,1388,878],"width":3,"radius":8}
{"kind":"callout","text":"…","at":[x,y],"target":[x,y],"size":48,"max_width":360}
- 🔴 The callout key is target, not dot. size is explicit per callout. - 🔴 pad takes fill, not color. Use explicit "white" always — "auto" samples pixel (1,1) and blows up on dark chrome. A white pad around a dark-mode capture reads as a deliberate frame, which is good. - 🔴 Transform order is src → scale → pad, so every target/bbox/at is in post-scale, post-pad canvas space: canvas_x = src_x * scale + pad_left.

Keep a _plan key on each image recording what you measured and why any callout was dropped, and a _fix key on anything you re-rendered. Next month that is the only record of the reasoning.

2.3 Measure, don't eyeball — the two harnesses

Coordinates read off a viewed image are not reliable. Measuring took the first-pass success rate from ~50% to 9/11.

OCR word boxes — for anything with text. ocr-words.ps1 (Windows.Media.Ocr, run under powershell.exe 5.1) over 2× PNG exports. Output is a top-level list of {"name", "lines":[{"text","words":[{"t","x","y","w","h"}]}]} — flat keys; read with encoding="utf-8-sig"; divide coords by 2. OCR text on small crops is poor, the coordinates are accurate, and coordinates are all you need.

The ink-run scanner — for everything OCR cannot see (toggles, chevrons, icons, filled buttons). Finds contiguous dark segments across a row band:

band = im[max(0,y-5):y+6, :]          # im = greyscale ndarray
cols = (band < 205).sum(axis=0)       # emit contiguous runs where cols>0, keep runs >= 2px
Reading it: a filled button is ONE solid run ([19-187]) · a toggle is a short run, and ON toggles are measurably wider than OFF ([246-272] vs [249-262]) · a chevron is a tiny run ([295-300]) · an all-ink row like [0-938] is grey chrome, not text — fall back to OCR word boxes for that row.

Dot-clearance scan, in src space:

r = max(5, round(12/scale)); p = im[y-r:y+r+1, x-r:x+r+1]
dark = (p<200).sum(); grey = (p<252).sum(); mn = p.min()    # dark must be 0
🔴 dark=0, min>=240 with a nonzero grey count is safe — that is light panel fill, not text. Only dark>0 is a real collision. 🔴 The threshold inverts on dark-mode captures. Check np.median(im) first and scan for light ink (patch > 120) — not doing so produced three phantom collisions in one image.

2.4 Recurring defect classes — check every plan against all nine

  1. Dot clips the last letter of the label it names — need ≥ ~25 canvas px past the measured text end, plus dot radius 7.
  2. Leader crosses body text. Clearing both endpoints does not clear the middle — solve for the leader's y at the obstacle's x. If no clean path exists, drop the callout.
  3. Box lands on or clips adjacent content.
  4. Dot recolours a coloured control — target the adjacent gap; if the gap is under 20 src px, drop it.
  5. Dot on an element's extreme corner reads as pointing at its neighbour.
  6. A callout must name something visible AND its dot must land on that thing. A dot in the dead centre of a gap points at neither neighbour.
  7. The leader must not run along the callout box's own border — when at[0] == target[0] it traces the outline; offset by ~45px.
  8. A dot can land in a word gap. min=255 proves no ink at that pixel, not that the dot cleared the text run — always compare against the measured line end.
  9. Orphaned words — a label wrapping so one word sits alone on the last line looks like a bug. Check chars <= max_width / (0.47 * size) before rendering. Fix by widening max_width, shortening the text, or — when the pad is what is constraining you — lowering scale, which buys proportionally more room for the same pad.

Wrapping arithmetic: line_height = size*1.3 + 6 · box_height = lines*lh + 40 · box_width = max_width + 52 · chars_per_line ≈ max_width / (0.47 * size). Always verify at[0] + max_width + 52 < canvas_width with ≥30px to spare, and that stacked boxes do not overlap in y. A horizontal leader needs at_y = target_y - box_height/2.

When a horizontal leader would cross a control, place the box higher and use a shallow diagonal, then solve the leader's y at the obstacle's x to prove clearance. Dropping a callout is always better than a leader through a control — five were dropped in one month, each recorded in its _plan.

2.5 Small crops still get annotated

Settled on precedent, not feel. The smallest already-annotated sources in the September issue are 325×619 (3 callouts), 346×168 (1), 349×319 (1), 354×495 (2). So a 330–390px crop does get annotated, typically 1–2 callouts. Query the existing set before arguing a crop is too small.

2.6 🔴 You cannot detect "was this annotated?" by counting red pixels

Tried and failed in both directions on real data: - False negative — a genuinely annotated image whose only mark is a thin 2–3px border scored 24 on a 700px downscale. - False positive — six untouched screenshots scored 912–3561 because the product UI is itself red/orange (filled Confirm/Create buttons, red ON toggles, a red back-arrow outline).

🔴 The authoritative test is SHA-256 against a pristine copy of the source. Byte-identical to clean ⇒ never annotated, full stop. So keep the clean source set for the whole month and do the final coverage accounting against it:

images in post = annotated + created-* + skip-class + annotated-at-capture — and that sum must close exactly.

2.7 Verify every render, always

View every rendered image, one per response. Across twelve batches the per-batch correction rate ranged from 0/7 to 5/11 — and every defect was invisible from the JSON. A render that looks obviously fine in the spec is exactly the one that ships an orphaned word.

2.8 Older notes

  • Tiny shots (taskbar icons, ~180px): don't ship raw. Inspect at 6× zoom to find exact icon bounds, then build a labeled composite — two states side-by-side on a dark canvas with Segoe UI labels (C:/Windows/Fonts/segoeui.ttf), scaled to equal height with Image.LANCZOS.
  • Paint out distractions (an error toast, an MCP-connection-failed banner) by sampling the bg pixel (im.getpixel()) and filling a rectangle with it — blends seamlessly on flat backgrounds.

3. PII discipline (tenant shots leak real data)

  • Mask corp emails (paint white/bg rectangle over [email protected]), neutralise avatars (grey ellipse over the face), crop names off people-result lists. Watch for the signed-in real account appearing in Copilot Studio / share dialogs.
  • Demo-tenant fictional names (Emily Davis, Contoso, Zava, agent names) are fine. First name in a greeting ("Ready when you are, Susanth") is fine (author's own).
  • Never bundle full name + email + role in one visible shot.

4. When the feature ISN'T in Sush's tenant

Order of preference: 1. Search internal first — glob prior monthly-recap image folders (static/images/blog/copilot-<month>-2026/, m365-agent-builder/, etc.). We reused the scheduled-prompt config dialog and found a share-agents-teams shot from past blogs (Sush's own content — zero copyright risk). 2. Search external for an OFFICIAL Microsoft image — Microsoft-owned/marketing images (recognisable by the Zava/Contoso demo brand, "Preview" badges, marketing polish). Use with an official-* filename + alt="Official Microsoft image…". 3. Add an honest disclaimer caption when using a borrowed/official shot because the feature isn't in the tenant: <p style="font-size:0.9rem;opacity:0.8;border-left:3px solid var(--border);padding:… "><em>…hadn't rolled out to my demo tenant yet, so the screenshot is Microsoft's own — not one of mine.</em></p> - 🔴 REJECT third-party bloggers' screenshots (e.g. an MVP's capture that shows their copyrighted book/content). Copyright risk on a monetised blog; a disclaimer does NOT fix it. Sush initially picked one of these — flag it, offer the official alternative.

Official monthly-roundup image gate — mandatory from August 2026

The official Tech Community monthly roundup is an image source in its own right. Do not wait until a colleague sends it after publication.

  1. Search the current month + previous two months using the discovery gate in whats-new-copilot-pack-playbook.md.
  2. Extract every roundup image URL and Microsoft-provided alt text before selecting any.
  3. Build an inventory with:
  4. source article + image URL;
  5. published/modified date;
  6. Microsoft-owned provenance;
  7. literal pixel observation written while viewing;
  8. intended blog section;
  9. USE / SKIP + reason;
  10. required demo/official-image variance caption.
  11. Vision-review every USE candidate with view. Microsoft alt text helps discovery but is not proof of what the pixels show.
  12. Re-run the image inventory if the roundup's modified date changes.

June 2026 tuition: Microsoft's late roundup contained 30 first-party images. It published six days after Sush's June recap and changed again on 14 July. The future workflow therefore checks at month-end, +7, +14 and the next monthly run.

5. Accuracy reconciliation (fix the notes when reality differs)

  • UI name ≠ release-note name: the PowerPoint picker showed "MAI Image 2.5 Flash" but the release note said "MAI-Image-2-Efficient" (different models in the same family). Options: (A) match the UI, (B) keep the release-note name + a note, (C) neutral title + name both in body ← Sush's pick. Verify the official name via microsoft_docs_search before deciding.
  • 🔴 Verify "is it coming" BEFORE writing "coming soon" (Rule #13). Sush assumed Share-to-a-Teams-team was "a separate feature coming in future"; the roadmap showed it's GA (March 2026), just rolling out. Correct disclaimer = "rolling out, appears in this same dialog," NOT "future." Fast roadmap lookup: web_fetch https://mc.merill.net/message/RM<id> returns clean roadmap text incl. the GA date (much easier than the JS-heavy roadmap site).

6. Editorial calls

  • Under-representation > misleading (Rule #8): if a shot doesn't match the section, remove it rather than keep it. We removed a "Claude-in-Excel" image from the Agents roundup — it illustrated neither bullet. Leave a section text-only rather than force a wrong/generic shot.
  • Same prompt, different annotation is a legit way to illustrate two adjacent features (Teams-meeting ref boxed for #8, style-reuse ref boxed for #9 — same underlying shot).
  • Pair prompt→result where it tells the story (Agent Mode prompt + finished deck; brand-kit picker + on-brand output).
  • Portrait/phone shots: cap width so they don't dominate — max-width:340px (phone), 400–480px (narrow panels/composites), display:block; margin:… auto to centre.

7. "Fade off" reorder (image-less sections to the end)

Sush likes the blog to fade from image-rich → text-only. Group the no-image sections at the tail. - Usually only 1–2 sections are out of place (the rest are already at the end). Move them + renumber the H2 headings. - Renumbering changes the auto-generated anchors (## 24. Title#24-title). You MUST update every Quick-Jump / cross-ref link to the changed anchors. - Hugo/goldmark anchor rule: lowercase, drop punctuation (periods removed → "2.5"→"25"), spaces→hyphens, em-dash " — " → double-hyphen "--". - Filename≠section-number is fine after a reorder (e.g. lab-25-defender-agents under section #24). Don't rename — the blog refs by filename; renaming is cosmetic + risky.

8. Validation (every time)

  • node scripts\check-blog-html.mjs after EVERY image placement and after the reorder — it validates alt text, src file existence, and Quick-Jump anchor resolution. Must be 0 errors.
  • Verify anchors on the LIVE preview (Rule #14): fetch http://localhost:1314/blog/<slug>/ and confirm each changed id="…" heading + its href="#…" link both exist. check-blog-html passing ≠ rendered-correct until you check the surface.
  • Orphan cleanup: after swaps, grep the blog for the old filename → confirm 0 references → then delete. Deleted 6 orphans this session safely.

9. Rules that always apply

  • STAGED ONLY — Sush reviews before any deploy (Rule #2 / #14). No git push in the screenshot phase.
  • Plain English, no jargon — it's Sush's voice (mum / 12-yo test).
  • Ask before adding new content sections (credits, notes) — but fixing notes to match reality is expected and welcome.

10. Two-phase handoff pattern

This work spans sessions. Write a handoff with: current state, the section→image map, disclaimers/captions to check, the webp-view gotcha, validation commands, and the kickoff line. Save alongside image-audit-<date>.md. A deep SME + fresh-eyes vision-QA pass is best done in a fresh-context session after the placement session (context fills up fast on a 40-image run).


Set 23 Jul 2026 from the July recap. Update after each monthly run with any new gotcha.