migrate-afl-widget
The parity track. The procedure the Software Factory follows to copy a live widget and prove it matches.
One ticket, one widget id. This skill is the parity track: copy the live widget exactly. If the item's track is redesign, stop and use the redesign-afl-widget skill instead. The live site is the source of truth; aflm-web source (../telstra-legacy-aflm-web, or the legacy refs on the tracker item) explains it; a prior port is a draft at best. Read apps/migration-tracker/content/docs/index.md first, then taxonomy.md, folder-convention.md, live-site-facts.md (breakpoints, rem scale, theming you must not rediscover) and run-parity-checks.md in the same folder.
Done means: parity passes the widget's full matrix against the live baseline using stub data, fixtures are in git and indexed in the tracker, the tracker status is verified, and a PR is open.
The ticket wins where it differs from this skill (base branch, whether to open a PR, whether to write to the tracker). If you cannot follow one of its instructions, do not invent a substitute that breaks a rule in AGENTS.md: stop and say what blocked you at the top of your report.
Before you start
Stop and report, rather than work around, if any of these fail. A run on a broken setup produces results nobody can trust.
bun install --frozen-lockfilesucceeds. A 403 on@two-circles/*meansGITHUB_PACKAGES_TOKENlacks the packages scope. Never stub a package, editbun.lockor apackage.jsonto get past it.curl -sf "$TRACKER_URL/api/items/<widget-id>"returns the item, unless the ticket says not to use the tracker.- No other run owns the ticket: the item's
sf_jobis empty, this run's job, or an earlier run that has finished (its PR merged or closed and no branch left open; that is a regeneration, see below), andgh pr list --state open --search "<TICKET-KEY>"andgit ls-remote --heads origin "*<TICKET-KEY>*"find nothing. If another run's branch or PR exists, stop and link it.
0. Load the item
curl -s "$TRACKER_URL/api/items/<widget-id>"Record this run on the item straight away, using the Software Factory job number this run was started with:
curl -s -X PATCH "$TRACKER_URL/api/items/<widget-id>" \
-H "authorization: Bearer $TRACKER_API_TOKEN" -H "content-type: application/json" \
-d '{"sfJob": "<job number>", "actor": "software-factory"}'If the item already has a different sf_job, an earlier run worked on it: read that run's PR or branch before starting, then follow Regenerating a widget below instead of starting over. Outside the Software Factory, leave sfJob unset.
Note priority, type, legacy_ref, optimizely_type, themeable, pages (with sample_url and region), dependencies, Pulse widgets it replaces, and existing artifacts. If the item has a prior port (migrated_unverified), read its audit row in apps/migration-tracker/data/legacy-audit.json and treat it as reuse, rework or discard accordingly.
Branch: claude/<TICKET-KEY>-migrate-<widget-id> from main, unless the ticket names another base.
1. Inspect the live widget
- Find the widget on the sample page and pick a root selector that matches exactly one element (the first match is used).
- Write
tools/parity/widgets/<widget-id>/widget.tswith one source (AFL, light). Addpartsfor every element whose look matters: live selector, build selector ([data-slot=...]), andattrsfor links and images. Addbehavioursfor every interaction the live widget has (hover, focus, click). Usehideandmaskonly for things outside the widget's control (ads, live clocks), and say why in the PR. - Run
parity:record(step 2) thenparity:inspect <widget-id> <width>at a card-layout width and a row-layout width. The dumps list every matched rule per element, pseudo-elements included, with media queries, in cascade order. This is your style reference. - Decide themeability. Club sites adopt the AFL markup with club theme tokens; never reproduce aflc-web markup. Set
clubReferenceto the same widget on a club page (tools/parity/src/clubs.tshas the domains), re-record, and runparity:inspect <widget-id> club. Club pages often carry the component under a different heading, so prove the reference is the same template before trusting the answer: its root and modifier classes (for exampleplaceholder--dark,--sidebar-featured) match the AFL instance, and itsrootselector matches exactly one element on the club page. Name the club instance and that evidence in the PR. If it lists rules that read club theme tokens, setthemeable: trueand use those tokens on the matching parts of the AFL markup (through--theme-*/--on-*utilities, never hard-coded club colours). If it lists none, setthemeable: false. Record the answer on the tracker item. - Dark mode: only if the live widget has a
.t-darkvariant somewhere on live. Add a source withmode: "dark"pointing at a page that shows it. - PATCH the tracker to
audited.
2. Capture stub data
bun run --cwd tools/parity parity:record <widget-id> records live.har.zip at every matrix width (live hosts only, ads and analytics aborted), drops incomplete entries and writes live.json (the frozen clock).
Choose the fixture source per widget and write extract.ts:
- Server-rendered (CMS data in the FreeMarker context, most widgets): scrape the widget's DOM into the block's
data. - Client-fetched (the widget calls an API, see the audit's
dataSource): take the JSON from the HAR or save it tostubs/*.json, list it understubsin the spec so live replays it, and map it to the block'sdatainextract.ts. - Legacy fixture: only when live has no instance (for example a dark variant that is not currently published), and say so in the PR.
extract.ts returns only fields the block's Zod schema reads. Fixtures land in packages/ui/src/<component>/fixtures/<fixture>.json as Block JSON ({ id, type, data, display }). Record each as a tracker artifact of kind fixture with its git path, the HAR as kind har. PATCH to stubbed.
3. Build the component
- Read the Optimizely type from Graph (
packages/content/src/optimizely/schema.graphql,bun run graph:pullto refresh). Never create or change a content type. If the type or an authored field the widget needs is missing, stop and comment on the ticket with exactly what is missing. Per-item data the BFF fills (for example an article's content label in an automated list) may be added to the schema as nullable when the BFF does not map it yet; list each one under "BFF gaps" in the PR. - Follow
docs/adding-a-block.mdfor the flavour fragment, registry entries and builder, and the taxonomy in the README for where the widget hangs. - Build in
packages/ui/src/<component>/: Tailwind only. Putlegacy-scaleon the widget's root element: it sets a 1px spacing unit and the live site's base type, so values copied from the inspect dump map 1:1 (2.4remmargin ismy-24), and convert any other rem value to px (text-[2.2rem]istext-[22px]), because the page root is the default 16px. Breakpoints:max-phablet:,max-tablet:,max-desktop:,desktop:,wide:,mobile:; club-adaptive colours through--theme-*; icons withSpriteIconfrom the live sprite. Reuse shared pieces inpackages/ui(for exampleWidgetHeaderorMediaThumbnailonce a widget has built them), extending them with the variant you need, and build a piece shared when other widgets will need it. Mirror the live element structure where it affects layout or rasterisation (pictures, pseudo-elements, wrappers). Putdata-sloton every part the spec compares. - Copy any static asset you need from the live version path, not from older copies in the repo. Delete any asset your change leaves unreferenced.
- Zod schema, story using the fixture, behaviour tests. No code comments. Tests assert what a user or the CMS sees: text, roles, links, which optional parts render and when. Never assert class names or Tailwind utilities; parity proves the styling.
- Only add values live shows. A lookup (label colours, icons per content type) gets the entries you saw on live plus the legacy default, nothing guessed.
- If you changed a shared component (
SpriteIcon, or any piece other widgets use), re-run parity for every verified widget that uses it and report the result. - PATCH to
built.
4. Iterate on parity
bun run --cwd tools/parity parity -g <widget-id>Club cells run the tokens check (18 clubs if themeable, carl otherwise). The width cells come from the --breakpoint-* screens in packages/config/tailwind-preset.css (plus one width at the top of any live CSS band they miss, capped at 25 cells), not a fixed list of six, so read the cell names from the report rather than assuming 375 to 1440. A token failure means packages/config/src/club-themes.css is stale or a new theme alias is missing: run bun run --cwd tools/parity parity:themes and commit the result; never edit the CSS by hand. Look at the attached club screenshots in the report to confirm the themed parts change and nothing else does.
Read failures in this order, because each hides the next:
- Element counts (
N live element(s), M build element(s)): wrong selector, missing part, or the block failed schema validation (look for a block error in the build screenshot). - Computed styles and boxes: fix the property named, then the first box offset. A box offset with no style diff is a margin or gap on an ancestor or sibling, often a cascade-order effect (a later media rule beating a modifier).
- Screenshots: open the diff in
.parity/results/. Edge-only lines are usually a pseudo-element (shadow, placeholder, overlay) or a different image source size. Zero diff in styles but whole-image differences means different image URLs or assets. - Behaviours: probe computed styles after the action with transitions settled (an outline mid-transition reads as 0px).
A screenshot cell has three outcomes. Zero differing pixels passes. A difference within PARITY_SCREENSHOT_THRESHOLD_PCT (a percent of pixels, default 0, so by default any difference fails) is a yellow warn: it counts as passed and the tracker shows it in yellow, with the differing pixels in diff.png. Above the threshold it fails. Do not raise the threshold to turn a failure into a warn; a warn still needs its cause listed in the PR, and the threshold is only set by the ticket or the run's configuration.
Never loosen the comparison to pass: no extra masks, no higher pixelThreshold, no dropped parts, no fewer widths. If something truly cannot match (a third-party embed, a live bug the client wants fixed), stop and flag it on the ticket with the evidence.
If cells still fail after you have worked through them, the widget is not done: leave the status at built, open the PR as a draft, and list each failing cell with its diff and your best explanation, marked as unconfirmed if you did not prove it.
5. Report and raise the PR
bun run --cwd tools/parity parity:publishposts the run to the tracker. Every cell must be green.- PATCH to
verified. - Run the repo checks from
AGENTS.md(bun run check,bun run typecheck,bun run test,bun run knip,bun run build). - Open the PR into
main(or as the ticket says) using the template: what changed, the matrix and its result, which fixture source was used and why, the themeability evidence, BFF gaps, anything masked or hidden and why, and the ticket link. Attach the tracker item URL.
Regenerating a widget
Regeneration rebuilds a widget that an earlier run already migrated (the item has a different sf_job, or the ticket says to regenerate). It reuses the item's ticket; it never opens a second one for the same widget.
- Record the rebuild before you change anything, from the repo root:
bun run --cwd tools/parity parity:rebuild <widget-id> --trigger "<why>" --change-log "<what is expected to change>". It writes the tracker-side rebuild and snapshots the prior build into it: the item's field mappings, its latest runs and screenshots, and the component's fixtures. Do not use the barePOST /api/items/<widget-id>/rebuildscall; without the snapshot there is nothing to diff.parity:rebuildrefuses to start while an earlier rebuild is still pending publish. - Regenerate the widget with steps 1 to 4. When the Optimizely content model changed, declare
replaces: "<old target>"on the new field's mapping (POST /api/items/<widget-id>/mappingswithfieldTypeandreplaces) so a dropped and an added field are reported as one replacement instead of an unconfirmed suggestion. parity:publishtags the runs with the rebuild, compares each newbuildscreenshot with the prior one (a yellow...-change.pngmarks differing pixels, at the same threshold) and stores the report shown on the item page: fields added, removed or changed, style and behaviour checks that changed state, and changed screenshots. Read it and summarise it in the PR; every changed screenshot needs a reason that is not just "regenerated".
Ticket and change-log policy
- The tracker change log is always written, for every rebuild, with the trigger, change log text, commit and rule.
- The item's original ticket is reused as the reference. Pass
--trackto also file one child ticket (Jira Sub-task or Linear child issue) under that original ticket, for a rebuild that needs its own review and history. It is idempotent per rebuild and never replaces the original; use it for a substantive change, not a re-record. - An item with no original ticket cannot take
--track(the tracker returns 409); the request is rejected before anything is written, so no change log exists; retry without--trackto record it. If filing the child fails, the change log entry stays andparity:rebuildprintschild ticket was not filed: <error>to stderr; report that in the PR. - Never edit the legacy repo (
telstra-legacy-aflm-web) to record or describe a rebuild.