AFL
SkillsBuild

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-lockfile succeeds. A 403 on @two-circles/* means GITHUB_PACKAGES_TOKEN lacks the packages scope. Never stub a package, edit bun.lock or a package.json to 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_job is 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), and gh pr list --state open --search "<TICKET-KEY>" and git 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

  1. Find the widget on the sample page and pick a root selector that matches exactly one element (the first match is used).
  2. Write tools/parity/widgets/<widget-id>/widget.ts with one source (AFL, light). Add parts for every element whose look matters: live selector, build selector ([data-slot=...]), and attrs for links and images. Add behaviours for every interaction the live widget has (hover, focus, click). Use hide and mask only for things outside the widget's control (ads, live clocks), and say why in the PR.
  3. Run parity:record (step 2) then parity: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.
  4. Decide themeability. Club sites adopt the AFL markup with club theme tokens; never reproduce aflc-web markup. Set clubReference to the same widget on a club page (tools/parity/src/clubs.ts has the domains), re-record, and run parity: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 example placeholder--dark, --sidebar-featured) match the AFL instance, and its root selector 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, set themeable: true and use those tokens on the matching parts of the AFL markup (through --theme-*/--on-* utilities, never hard-coded club colours). If it lists none, set themeable: false. Record the answer on the tracker item.
  5. Dark mode: only if the live widget has a .t-dark variant somewhere on live. Add a source with mode: "dark" pointing at a page that shows it.
  6. 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 to stubs/*.json, list it under stubs in the spec so live replays it, and map it to the block's data in extract.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

  1. Read the Optimizely type from Graph (packages/content/src/optimizely/schema.graphql, bun run graph:pull to 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.
  2. Follow docs/adding-a-block.md for the flavour fragment, registry entries and builder, and the taxonomy in the README for where the widget hangs.
  3. Build in packages/ui/src/<component>/: Tailwind only. Put legacy-scale on 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.4rem margin is my-24), and convert any other rem value to px (text-[2.2rem] is text-[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 with SpriteIcon from the live sprite. Reuse shared pieces in packages/ui (for example WidgetHeader or MediaThumbnail once 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). Put data-slot on every part the spec compares.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

  1. 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).
  2. 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).
  3. 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.
  4. 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

  1. bun run --cwd tools/parity parity:publish posts the run to the tracker. Every cell must be green.
  2. PATCH to verified.
  3. Run the repo checks from AGENTS.md (bun run check, bun run typecheck, bun run test, bun run knip, bun run build).
  4. 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.

  1. 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 bare POST /api/items/<widget-id>/rebuilds call; without the snapshot there is nothing to diff. parity:rebuild refuses to start while an earlier rebuild is still pending publish.
  2. 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>/mappings with fieldType and replaces) so a dropped and an added field are reported as one replacement instead of an unconfirmed suggestion.
  3. parity:publish tags the runs with the rebuild, compares each new build screenshot with the prior one (a yellow ...-change.png marks 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 --track to 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 --track to record it. If filing the child fails, the change log entry stays and parity:rebuild prints child 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.