Skip to content

ux-map

Index a capture of an app, read back every screenshot of its most recent build, then pin a design note to a region of one of those screens, list the queue, and resolve it.

Endpointhttps://engineering.mcp.devfellowship.com/mcp
Tools7
Backing dataengineering.ux_map_apps, ux_map_captures, ux_map_screens (written through one RPC) and engineering.ux_map_annotations.
ToolWhat it does
index_ux_map_captureRecord one capture — the app, the role, the viewport and its screens — through engineering.index_ux_map_capture. The only write path into those three tables for a human identity. Idempotent on (app, role, viewport, digest).
delete_ux_map_captureDestructive. Remove one capture and, by CASCADE, its screens and its validations. The only delete path that exists. dry_run defaults to true; admin only.
update_ux_map_captureCorrect the prose and provenance of one indexed capture — coverage_note, app_version, commit_sha, and nothing else. The only update path that exists. dry_run defaults to true; member level.
annotate_ux_map_regionPin a note to a region. The anchor comes from that screen’s regions.json.
list_ux_map_annotationsRead the queue. Filter by app, screen, source file, component, author, status or viewport.
resolve_ux_map_annotationMove a note between open and addressed, and record the request pull request it produced.
ux_paths_app_imagesEvery screenshot of one app’s latest build, as urls or as one zip. Read-only.

ux_paths_app_images answers the question an agent actually has: show me what this app looks like today. It takes app — the ux-map slug (dfl-learn) or the repository as <owner>/<repo> — plus the optional role, capture_id, viewport and format.

A build is (app_version, commit_sha), not app_version

Section titled “A build is (app_version, commit_sha), not app_version”

itera-player has eight captures spanning nineteen days that all read 2026-08-12-63cdc99, with eight different commits. Grouping on the version string alone would union three weeks of history and call it “the latest version”, so the pair is the key.

A capture is per role, so the default unions the roles

Section titled “A capture is per role, so the default unions the roles”

dfl-learn at 2026-08-18-77be345 has three captures — superadmin, community_member, anonymous. A screen only a superadmin reaches is still a screen of that build, so every image comes back labelled with the role that saw it. role narrows to one. capture_id pins one capture and the answer says resolved_from: "capture_id (pinned — this is NOT necessarily the latest build)", so a historical read cannot be mistaken for a current one.

viewport — defaults to desktop, never to a silent mix

Section titled “viewport — defaults to desktop, never to a silent mix”

desktop | mobile | both. A mobile capture is addressed as the role <role>@mobile until the viewport column lands, and both spellings are read — the column wins when it exists, because it is the fact; the suffix is the pre-migration spelling of the same fact; a row with neither is a desktop capture, because every capture indexed before the mobile pass was taken at 1280.

Each image carries its own viewport and its base role, so superadmin rather than superadmin@mobile — that string is an address, not a role.

The default is desktop and not both, because a both default would silently double every answer the day the mobile pass lands, and a caller who asked for “the images” would get two pictures per screen with no idea why. When the app has no mobile capture at all, viewport.missing_sibling says so.

One capture per role and viewport — a re-index supersedes, it does not add

Section titled “One capture per role and viewport — a re-index supersedes, it does not add”

Unioning the captures of a build is right across roles and wrong within one. A role at a viewport at a build has exactly one current capture; a second is a re-index, and the older row is superseded.

This is not hypothetical. dfl-website-brand carries two anonymous capture rows at app_version 2026-09-02-97f1be5 with the same 39 screens, so the naive union returned 78 images for a 39-screen site — every page twice, with screen_id repeated.

The dropped row comes back in superseded_captures, with its digest and the reason, rather than being hidden. The double row is a defect in the index, not in the read: index_ux_map_capture is idempotent on p_digest, so two rows mean it ran twice with two different digests for the same screens — worth somebody’s attention. What this tool refuses to do is pass that through as duplicated output.

distinct_images — count pictures by DIGEST, not by url

Section titled “distinct_images — count pictures by DIGEST, not by url”

Every screen gets its own media object, so distinct_urls always equals count and says nothing about whether two screens photographed the same pixels. distinct_images counts digests, and identical_images names the pairs.

The dfl-lesson-studio capture of 2026-09-02 is the case in point: 31 screens, 31 urls, 29 distinct images.

pairwhy
editor_export_modal + editor_publish_opens_export_modaldeliberate — with no completed export, Publicar opens the export modal (that repo’s PR #315)
preview_mobile + mobile_preview_screensunexplained, per the capture’s own coverage note: one of the two is not independently photographed

Surfacing the pairs is what lets a reviewer tell those two cases apart. A deliberate duplicate is fine; an accidental one means a screen nobody has actually looked at — exactly the gap a UX map exists to close. Rows with no digest are skipped rather than grouped, because a missing digest is not evidence of sameness.

anonymous_access — who can open each url

Section titled “anonymous_access — who can open each url”

Every image carries anonymous_access: open when somebody with no DFL session can open that url, members_only when they cannot, unknown when the probe got no answer.

It is measured, not read off a column. public.media.visibility sits behind an owner-scoped RLS policy, so a caller who did not upload the screenshot gets no row at all and cannot tell private from not visible to me. The tool issues one anonymous GET per distinct url — no token, no cookie, redirect: "manual" — and reads the answer. Measured against production on 2026-09-02:

object tieranonymous GETfollowed
a members object401401
a private object302200 image/png

redirect: "manual" is what makes it cheap: a private object answers 302 to a presigned url and the bytes never move.

The images are fetched here with the caller’s own token, packed with a manifest.json, uploaded as one media object, and its url is returned. Above 25 MB or 300 images it refuses, sets zip_skipped_reason, and returns the url list instead — so a caller is never left with nothing. The archive is stored uncompressed: PNG is already deflate-compressed, so its size is the sum of its images.

The archive’s visibility follows its content. private when every screenshot inside already opened anonymously — that is the link-capability tier an external designer can open with no DFL login. members the moment one members-only image is inside, because an archive is only as shareable as its least shareable member: a private zip of members-only screenshots would make every one of them readable by anyone holding a single url, which is the door dfl-ux-paths#81 closed. Never public.

index_ux_map_capture calls one SECURITY DEFINER function with the caller’s own JWT. One transaction writes the app row, the capture row and every screen row, so a capture never claims screens it does not have.

Required: app_id, role, digest (sha256: plus 64 lowercase hex) and an https:// artifact_url. Optional: viewport (default desktop), screens, display_name, repo_full_name, business_unit_id, artifact_media_id, app_version, commit_sha, captured_at, coverage_note, run_id.

Each screen object accepts screen_id (required) plus name, route, screenshot_url, screenshot_media_id, screenshot_digest, regions_url, regions_media_id and source_ref_file — and nothing else. An unknown key is refused by the tool and by the database; a typo is never quietly dropped.

Two arguments that do not exist, on purpose

Section titled “Two arguments that do not exist, on purpose”
Not an argumentBecause
indexed_by / user_id / authorThe database stamps the author from auth.uid(). A caller-supplied author makes the tool a way to write a map as somebody else, and provenance is the whole value of indexing one.
screens_total / screens_with_shotThe database counts the screens payload. Coverage is the number a reviewer judges a map by, so the author is the last party who should assert it.

role says who was looking. viewport says on what. They are two columns, and a viewport must never be written into a role: the tool refuses an @ in role, because tenant_admin@mobile would be listed as a role by every menu that reads the distinct roles of an application. That spelling was the earlier design; a column replaced it on 2026-09-02.

Known values are desktop — the default, and every capture taken before that date — and mobile. Anything else follows the <W>x<H> convention in CSS pixels, lower case, e.g. 390x844. It is free text on purpose, exactly as role is: the set of viewports a product is captured at is not the database’s to close. The value is lower-cased, so Mobile and mobile are one lane.

The tool sends p_viewport only when it is not desktop, so a desktop call still resolves against a database where dfl-schema 20260902160000_engineering_ux_map_captures_viewport.sql has not applied. A call that genuinely asks for another viewport is answered migration_not_applied, naming that migration — it is never filed as a desktop row instead, because a capture under the wrong viewport is worse than a missing one.

(app, role, viewport, digest) identifies the capture, and digest identifies the content within that lane. Re-indexing identical content writes nothing and returns the existing row with already_indexed: true, which the tool reports at the top level of its result. Read it: a no-op reported as a write is the failure mode here. To record new content, change the content and its digest.

index_ux_map_capture needs the engineering.index_ux_map_capture function from dfl-schema migration 20260813235500_engineering_ux_map_index_rpc.sql, which is at a human merge gate. Until it merges the tool answers migration_not_applied and names that migration — it never degrades to a silent success. A caller whose IAM global level is below 50 (member) is answered forbidden.

delete_ux_map_capture is the other half of the pair, and it exists because there was no delete path at all. Measured read-only against production on 2026-09-02: authenticated holds SELECT on the ux_map tables and nothing else, and the only DELETE policies in the schema are on ux_map_annotations and ux_map_validations. A capture indexed by mistake — a smoke test, a wrong role, a wrong viewport, a capture of a broken build — could not be removed by anybody except the database owner.

dry_run defaults to true. The first call counts everything that would change, deletes nothing, evaluates every guard, and prints the digest that confirm_digest needs. Committing without that digest is refused, because a uuid is not something a caller can sanity-check by looking at it.

The gate is IAM global level 80 (admin) — deliberately narrower than the level 50 index_ux_map_capture needs. A wrong capture is additive and self-correcting; a wrong delete takes the screens and every human validation of that capture, with no record that the row ever existed.

Every one of these was read off pg_constraint, not inferred:

  • Screens (engineering.ux_map_screens) — ON DELETE CASCADE. Deleted, and counted.
  • Validations (engineering.ux_map_validations) — ON DELETE CASCADE. Deleted, and counted.
  • Annotations (engineering.ux_map_annotations) — ON DELETE SET NULL. They survive, keeping the anchor and the capture_digest.
  • A capture that measured against this one (baseline_capture_id) — ON DELETE SET NULL, which blocks the delete. See the caution below.
  • The app row (engineering.ux_map_apps) — never touched. Deleting the last capture of an app leaves the app row, on purpose.

This tool never deletes an annotation and could not if it tried. What a note loses is the row saying which capture it was written against, so allow_detached_annotations makes that an explicit choice rather than a discovery in the counts afterwards. Deleting feedback stays where it already is: the author-only DELETE policy on that table.

deleted and reason sit at the top level. Every refusal answers deleted: false with its own reason, and so does an id that is already gone (not_found — the state you asked for, but not a confirmation that this call removed anything).

The reason values, in full:

  • deleted — the row is gone; counts records what went with it.
  • dry_run — nothing happened; would_change is the projection.
  • not_found — no capture has that id.
  • confirm_digest_required and confirm_digest_mismatch — nothing happened; re-read the dry run.
  • annotations_present — nothing happened; acknowledge with allow_detached_annotations.
  • baseline_referrers_present — nothing happened, and it cannot happen until the referrers are reset.

There is no deleted_by, user_id or actor argument — the database reads the deleter from auth.uid(), and a delete leaves no other trace. There is no app_id, role or date filter either: one capture per call, addressed by id, because a destructive tool scoped by a predicate eventually deletes what the predicate also matched.

delete_ux_map_capture needs the engineering.ux_map_delete_capture function from dfl-schema migration 20260902170000_engineering_ux_map_delete_capture_rpc.sql, which is at a human merge gate. Until it merges the tool answers migration_not_applied and names that migration. There is no fallback and there must not be: no other delete route exists, and psql against production is read-only.

update_ux_map_capture is the third member of the capture family, and it exists for the same reason the other two do: the table grants authenticated nothing but SELECT. Measured read-only against production on 2026-09-03, the relacl on engineering.ux_map_captures is authenticated=r, and the single policy on it is a SELECT policy with USING true. So a coverage_note that says something false could not be corrected by anybody except the database owner.

That is not hypothetical. Two rows carried a false sentence on 2026-09-03: one claimed a capture “is not geometrically uniform”, which is false by construction — capture.mjs reads the IHDR of every PNG and throws on a mismatch — and one counted “the 36 images” of a capture that photographed 33. The generator was repaired; the rows could not be.

It edits three columns, and refuses every other one

Section titled “It edits three columns, and refuses every other one”
  • coverage_note — prose. An account of the capture, not a reading off it.
  • app_version — provenance the indexer copied from the flows document. It drifts.
  • commit_sha — provenance the caller supplied.

Everything else is evidence, in three kinds, and a patch that names one of them is refused whole with reason: "field_not_editable" — the good fields are never applied without the bad ones.

KindColumnsHow an edit breaks it
identity / laneid, app_uuid, run_id, role, viewportThree of them build uq_ux_map_captures_app_role_viewport_digest, so an edit silently re-keys the capture.
read off the artifactdigest, artifact_url, artifact_media_id, captured_at, screens_total, screens_with_shotA rewritten digest makes the row claim content it does not have — and every later re-index of the real content then answers already_indexed for a row that is not it.
derivedbaseline_*, largest_movement_*, movement_measured_atEditing a conclusion without re-running the comparison turns a measurement into an opinion.

The line is: a claim about the evidence may be corrected; the evidence may not. To change the evidence, index a new capture — the newest capture wins and the history stays.

edited_at and edited_by are closed for the reason they exist. An audit stamp a caller can write is not an audit stamp.

Three states, and they are different:

  • send a field → set that column;
  • omit a field → leave that column alone;
  • send it as null → clear that column.

The tool never coalesces the last two. A caller correcting a note would otherwise echo back every field it read, nulls included, and blank the provenance of the row it was fixing.

A call that names no field at all is refused with reason: "empty_patch", before any database call. An UPDATE that changes no column but stamps edited_at and edited_by records an edit that did not happen.

updated and reason sit at the top level, on every branch. The failure mode here is a caller reading “success” and believing the prose changed.

  • updated — the row holds the new values.
  • dry_run — nothing was written; read would_change, and check capture names the row you meant.
  • not_found — no capture has that id. A state, not an error, and not a confirmation.
  • no_change — the row already held everything you sent, so edited_at was not stamped.
  • empty_patch and coverage_note_too_long — nothing was written; the message says what to fix.
  • field_not_editable — nothing was written; rejected_fields names what you asked for and editable_fields names what is allowed.

before and after come back for the patched fields only, on every branch. before is the only undo path: these columns keep no version history. That is also why this tool needs no confirm_digest where the deleter does — a wrong edit is reversed by calling again with the old text, and a wrong delete is not reversed at all.

Do not hand-write a note that a generator produced. Run that generator against the capture manifest and send what it returns, so the note stays a function of the artifact instead of a remembered paraphrase.

update_ux_map_capture needs the same IAM global level index_ux_map_capture needs, and deliberately not the level 80 delete_ux_map_capture needs. Whoever may write a coverage_note must be able to correct one, and an edit is reversible where a delete is not.

There is no edited_by, user_id or actor argument — the database reads the editor from auth.uid() and stamps it. There is no app_id, role or date filter either: one capture per call, addressed by id.

The tool needs the engineering.ux_map_update_capture function from dfl-schema migration 20260903090000_engineering_ux_map_update_capture_rpc.sql, which is at a human merge gate. Until it merges the tool answers migration_not_applied and names that migration. There is no fallback and there must not be: authenticated holds no UPDATE privilege on the table, and psql against production is read-only.

Tainan decided the shape on 2026-08-13, answering “screen or region”: region, anchored to the source file plus the component identity, and not to the pixel bounding box.

The box locates the click. What gets stored is the component identity that regions.json already carries.

A stored box is a fact about one rendering; (file, component, tag, occurrence) is a fact about the code. Only the second still means something after a re-layout — and a re-layout is the ordinary case, not the exotic one.

annotate_ux_map_region has no viewport argument, and none may be added. The database copies the viewport from the capture you named, for the same reason it takes the author from your JWT: what you were looking at is not a claim the writer gets to make. It joins the frozen anchor set, so nobody can move a note onto an image its author never saw.

It is a stored column rather than a join through capture_id, and that is deliberate. capture_id is nullable with ON DELETE SET NULL — retention deletes unvalidated captures and the feedback has to outlive them. A viewport filter built on that join would return NULL for exactly the oldest annotations, the ones nobody has addressed yet, and a read filter that silently drops the rows the queue exists to surface is worse than no filter.

list_ux_map_annotations takes viewport as an optional filter. Omit it to read every lane, which is what the tool did before the column existed. Pass it to ask “what is open on this screen at this viewport” — a note raised on a desktop-only sidebar is not feedback about the mobile image. Before the migration applies, a list without a viewport still answers, and a list with one is refused rather than served unfiltered.

If the component is gone, the annotation is broken and must be reported as broken. It is never moved onto a plausible neighbour.

The temptation is real: a renamed component usually leaves an element of the same tag at the same box, so a resolver that matched “the element that looks right” would answer confidently and wrongly — and a pixel diff of the two captures would report zero change. Losing a note is recoverable. A note attributed to the wrong element is a wrong statement that gets acted on.

The database enforces this three ways, so no tool has to be trusted with it:

  1. every anchor column is NOT NULL, so a component-less “screen note” cannot be stored at all;
  2. authenticated holds UPDATE on six columns and no others, so the anchor is unwritable one layer below RLS;
  3. a BEFORE UPDATE trigger raises on any anchor change even for a role that does hold the privilege.

pin_rel_* and region_rel_* are fractions of the screen; pin_in_region_* is the fraction of the region box, and it is the pair a surviving pin is redrawn from against the region’s box in the current capture. A component that grew from 200 px to 400 px keeps the pin on the same part of itself. CHECK constraints reject anything outside 0..1.

ActionWho
Readany signed-in user. anon is refused a layer below RLS.
Write a noteyou, as yourself — author_id = auth.uid() is enforced.
Resolve / re-openanyone signed in. The designer raises them, the engineers close them.
Edit the textthe author only, enforced by a trigger.
Delete a notethe author only — and no tool offers it. Deleting somebody’s feedback is not resolving it, and delete_ux_map_capture cannot reach a note either.
Correct a capture’s proseany member (level 50), through update_ux_map_capture. The database stamps edited_by from auth.uid().

These tools carry the caller’s user-JWT. There is no service_role path: an opinion a service identity writes on somebody’s behalf is an assertion, not evidence. ux_paths_app_images follows the same rule on both of its gates: it reads the index with the caller’s client, and its zip mode fetches every screenshot with the caller’s own bearer token.

The ux-paths viewer writes the same table directly with the reader’s own session, which is the correct data path for a UI. These tools are the agent’s path to it, not a second one for the browser.

  • capture_id is required on INSERT — an annotation is born from a real capture. It is nullable so retention can clear it: the note outlives the capture, keeping its content-addressed capture_digest as provenance.
  • A patch with no fields is refused rather than reported as success, and an RLS-filtered UPDATE that touches zero rows raises.
  • Requires the engineering.ux_map_annotations migration (devfellowship/dfl-schema#793), which is at a human merge gate. Until it merges these tools return the database’s own error and never degrade to a silent success.