cee097b75fe4d1b29f0debd8af938ba6bf729d53
max
  Tue Aug 11 08:18:42 2026 -0700
hgBlat: new client-rendered BLAT search form and sortable results page, refs #37893

An opt-in modern alternative to the classic hyperlink output, gated by the
blatNewForm / blatNewPage hg.conf flags. hgBlat.c emits the hit data and form
config as inline JSON (hgBlatData / hgBlatFormData) into empty containers, and
hgBlat.js builds the UI: the search form, a sortable/filterable DataTable with
identity and query-coverage bars, a docked per-hit detail panel, a FASTA query
viewer, and share-link and rename-track modals.

- hgBlat.css: all styling for both pages, loaded via webIncludeResourceFile
(not injected from JS); layout uses CSS classes rather than inline styles.
- utils.js: setupGenomeSelector, the combobox genome picker the form uses.
- hgc.c: blatOldTracks / blatKeepResults - the "Keep results" checkbox lets a
user accumulate BLAT result tracks instead of each search replacing the last.
- hgConfCatalog: register the blatNewForm / blatNewFormBanner gates.
- blatExample.fa: the example query the form loads on demand.

Markup is built in JS (escaped values via htmlEncode, everything else set through
DOM properties); no HTML strings in the C or a template engine.

diff --git src/hg/hgBlat/hgBlat.c src/hg/hgBlat/hgBlat.c
index 3f71514befe..31a0d6655ce 100644
--- src/hg/hgBlat/hgBlat.c
+++ src/hg/hgBlat/hgBlat.c
@@ -607,30 +607,31 @@
 return locusConn;
 }
 
 static void printBlatResultsApp(struct psl *pslList, char *database, char *organism, char *browserUrl,
     char *hgcUrl, char *pslName, char *faName, char *customText, char *uiState, char *unhideTrack,
     struct sqlConnection *locusConn, struct subText *subList)
 /* "Table" output mode: emit the hit data as an inline JSON object plus an empty container, and let
  * hgBlat.js build the UI (summary strip, DataTable with identity/coverage bars, detail panel).
  * All presentation lives in hgBlat.js; this function only assembles data.
  * On a fresh search the per-hit "Alignment details" links go to hgc's htcUserAli (which reads the
  * ephemeral trash .pslx/.fa); on a shared-link reopen (pslName NULL) there is no trash, so they go
  * to htcBlatAlign instead, which rebuilds each alignment from the durable bigPsl custom track. */
 {
 struct psl *psl;
 jsIncludeDataTablesLibs();
+webIncludeResourceFile("hgBlat.css");
 jsIncludeFile("hgBlat.js", NULL);
 
 struct jsonWrite *jw = jsonWriteNew();
 jsonWriteObjectStart(jw, NULL);
 
 jsonWriteObjectStart(jw, "config");
 /* For assembly/GenArk hubs the internal names carry a "hub_NNN_" prefix; drop it so the Assembly
  * field reads cleanly (and doesn't show the prefix twice), matching the BLAT Results page title. */
 jsonWriteString(jw, "db", trackHubSkipHubName(database));
 jsonWriteString(jw, "organism", trackHubSkipHubName(organism));
 jsonWriteString(jw, "queryName", pslList->qName);
 jsonWriteNumber(jw, "querySize", pslList->qSize);
 jsonWriteNumber(jw, "hitCount", slCount(pslList));
 jsonWriteBoolean(jw, "multiQuery", pslListMultiQuery(pslList));
 jsonWriteBoolean(jw, "hasLocus", locusConn != NULL);
@@ -784,50 +785,78 @@
             }
         sqlFreeResult(&sr);
         }
     jsonWriteObjectEnd(jw);
     freeMem(inTabUrl);
     freeMem(newTabUrl);
     }
 jsonWriteListEnd(jw);    // hits
 jsonWriteObjectEnd(jw);  // root
 
 printf("<div id='blatResults'></div>\n");
 jsInlineF("var hgBlatData = %s;\n", jw->dy->string);
 jsonWriteFree(&jw);
 }
 
+static void printBlatBannerStyle()
+/* Emit the .blatBanner rule for the classic, C-rendered pages.  Those don't load hgBlat.js, which
+ * is where the new pages get this class from, so define the same thing here; keep the values in
+ * sync with the .blatBanner rule in hgBlat.js.  Only the first call emits anything, so a page may
+ * carry more than one banner without repeating the stylesheet. */
+{
+static boolean styleDone = FALSE;
+if (styleDone)
+    return;
+styleDone = TRUE;
+printf("<style>"
+       ".blatBanner{background:#fbf3e2;border:1px solid #d9bd82;padding:10px 14px;margin:12px 0 20px;"
+       "font-size:14px;color:#1e2833}"
+       ".blatBanner a{color:#003a72}"
+       ".blatBanner a:hover{color:#8b1a1a}"
+       "</style>");
+}
+
+static void printNewFormBanner()
+/* On the classic search form, offer the JavaScript-built one.  Without this the new form's "go back
+ * to the original page" link is a one-way door.
+ * Shown wherever the new form is enabled (blatNewForm), which is what makes the round trip work;
+ * blatNewFormBanner overrides that in either direction.  On a machine where the new form is off
+ * there is nothing to advertise, so nothing is printed. */
+{
+if (!cfgOptionBooleanDefault("blatNewFormBanner",
+			     cfgOptionBooleanDefault("blatNewForm", FALSE)))
+    return;
+printBlatBannerStyle();
+printf("<div class=\"blatBanner\">"
+       "We are testing a <a href=\"hgBlat?blatNewForm=1&%s=%s\">new BLAT search page</a>. "
+       "If you have feedback on this new page, do not hesitate to let us know via "
+       "<a href=\"mailto:genome@soe.ucsc.edu\">genome@soe.ucsc.edu</a>.</div>\n",
+       cartSessionVarName(), cartSessionId(cart));
+}
+
 static void printNewDisplayBanner(char *uiState)
 /* On the classic hyperlink results page, offer a one-click switch to the modern Table display.
  * The link sets the blatNewPage cart variable (so the choice sticks for future searches) and
  * reopens the current results (blatReopen) in the new format.
  * The banner is OFF by default while the new page is still being tested; set blatNewPageBanner=on
  * in hg.conf to advertise the new page - without releasing new CGIs.  The new display itself stays
  * reachable by users who already opted in or use a direct blatNewPage=1 link. */
 {
 if (!cfgOptionBooleanDefault("blatNewPageBanner", FALSE))
     return;
-/* Reuse the new page's ".blatBanner" look (house style: wheat box, square corners, navy links).
- * The classic page doesn't load hgBlat.js's injected CSS, so define the same class here.  Keep these
- * values in sync with the .blatBanner rule in hgBlat.js. */
-printf("<style>"
-       ".blatBanner{background:#fbf3e2;border:1px solid #d9bd82;padding:10px 14px;margin:12px 0 20px;"
-       "font-size:14px;color:#1e2833}"
-       ".blatBanner a{color:#003a72}"
-       ".blatBanner a:hover{color:#8b1a1a}"
-       "</style>"
-       "<div class=\"blatBanner\">"
+printBlatBannerStyle();
+printf("<div class=\"blatBanner\">"
        "We are testing a BLAT results page, with a sortable and filterable table of hits, "
        "gene loci and query coverage. "
        "<a href=\"hgBlat?blatNewPage=1&blatReopen=1&%s\">Try the new page</a>.</div>\n", uiState);
 }
 
 void showAliPlaces(char *pslName, char *faName, char *customText, char *database,
            enum gfType qType, enum gfType tType, 
            char *organism, boolean feelingLucky)
 /* Show all the places that align. */
 {
 boolean useBigPsl = cfgOptionBooleanDefault("useBlatBigPsl", TRUE);
 struct lineFile *lf = pslFileOpen(pslName);
 struct psl *pslList = NULL, *psl;
 char *browserUrl = hgTracksName();
 char *hgcUrl = hgcName();
@@ -2306,30 +2335,32 @@
     cartWebEnd();
 
 gfFileCacheFree(&tFileCache);
 }
 
 void askForSeq(char *organism, char *db)
 /* Put up a little form that asks for sequence.
  * Call self.... */
 {
 /* ignore struct serverTable* return, but can error out if not found */
 findServer(db, FALSE);
 
 char *userSeq = NULL;
 char *type = NULL;
 
+printNewFormBanner();
+
 printf(
 "<FORM ACTION=\"../cgi-bin/hgBlat\" METHOD=\"POST\" ENCTYPE=\"multipart/form-data\" NAME=\"mainForm\">\n"
 "<H2>BLAT Search Genome</H2>\n");
 cartSaveSession(cart);
 puts("\n");
 puts("<INPUT TYPE=HIDDEN NAME=changeInfo VALUE=\"\">\n");
 puts("<TABLE class='hgBlatTable' BORDER=0 WIDTH=80>\n");
 printf("<TR>\n");
 printf("<TD ALIGN=CENTER style='overflow:hidden;white-space:nowrap;'><label for='genomeSearch'>Genome:</label>");
 printf(" <INPUT TYPE=CHECKBOX id=allGenomes NAME=allGenomes VALUE=\"\">");
 printf(" <label for='allGenomes'> Search all genomes</label>");
 printf("</TD>");
 
 printf("<TD ALIGN=CENTER>Assembly:</TD>");
 printf("<TD ALIGN=CENTER><label for='type'>Query type:</label></TD>");
@@ -2514,30 +2545,144 @@
 cartSaveSession(cart);
 printf("<INPUT TYPE=HIDDEN NAME=org VALUE=\"%s\">\n", organism);
 printf("<INPUT TYPE=HIDDEN NAME=db VALUE=\"%s\">\n", db);
 type = cartUsualString(cart, "type", "");
 printf("<INPUT TYPE=HIDDEN NAME=type VALUE=\"%s\">\n", type);
 sort = cartUsualString(cart, "sort", "");
 printf("<INPUT TYPE=HIDDEN NAME=sort VALUE=\"%s\">\n", sort);
 output = cartUsualString(cart, "output", "");
 printf("<INPUT TYPE=HIDDEN NAME=output VALUE=\"%s\">\n", output);
 userSeq = cartUsualString(cart, "userSeq", "");
 printf("<INPUT TYPE=HIDDEN NAME=userSeq VALUE=\"%s\">\n", userSeq);
 printf("<INPUT TYPE=HIDDEN NAME=Submit VALUE=submit>\n"); 
 printf("</FORM>\n");
 }
 
+static void blatFormJsonList(struct jsonWrite *jw, char *name, char *list[], int count)
+/* Write a JSON array of the strings in list, for a dropdown in the JS-built form. */
+{
+jsonWriteListStart(jw, name);
+int i;
+for (i = 0;  i < count;  ++i)
+    jsonWriteString(jw, NULL, list[i]);
+jsonWriteListEnd(jw);
+}
+
+void askForSeqJs(char *organism, char *db)
+/* "New form" mode: emit the real <form> plus an inline hgBlatFormData object and an empty
+ * container, and let hgBlat.js build the controls inside that form.  Mirrors how showAliPlacesTable
+ * feeds the results page, and reuses hgBlat.js's stylesheet so both pages look alike - including
+ * the gold page-title bar, which is the framework's own #sectTtl styled by .subheadingBar.
+ *
+ * The controls hgBlat.js builds are ordinary named form fields, so the browser serializes them
+ * itself (the file input included) and Submit / Lucky / Clear stay plain submit buttons handled by
+ * the same C code as the classic form.  Nothing is mirrored into a shadow form on submit. */
+{
+/* ignore struct serverTable* return, but can error out if not found */
+findServer(db, FALSE);
+
+printf("<form action=\"../cgi-bin/hgBlat\" method=\"POST\" enctype=\"multipart/form-data\" "
+       "name=\"mainForm\">\n");
+cartSaveSession(cart);
+printf("<input type='hidden' name='changeInfo' value=''>\n");
+/* Set by the genome search bar's onSelect below, which then submits to reload in the new db. */
+printf("<input type='hidden' name='db' value='%s'>\n", db);
+
+/* The genome/assembly picker is the shared search bar from web.c, with its real autocomplete over
+ * every species and assembly.  It is emitted here and relocated into the layout by hgBlat.js, so
+ * the picker and its wiring stay in one place instead of being reimplemented in the new form. */
+printf("<div id='blatGenomeHolder'>\n");
+jsIncludeAutoCompleteLibs();
+char *searchBarId = "genomeSearch";
+printGenomeSearchBar(searchBarId, "Search any species, genome or assembly name", NULL, TRUE, NULL, NULL);
+printf("</div>\n");
+/* Unlike the classic form (and hgPcr/hgTables/hgVai/hgLiftOver/hgCustom, which must reload to
+ * rebuild db-dependent menus), nothing here needs a round trip on genome change: the label is
+ * updated by setupGenomeSearchBar itself, and blatFormSetDb() updates the hidden db field and the
+ * db-bearing sidebar links in place.  So no submit() - picking a genome no longer reloads. */
+/* setupGenomeSelector is the combobox version of setupGenomeSearchBar - same config, caret inside
+ * the field, common assemblies as pills underneath.  Swapping the two function names is the whole
+ * change, so the other CGIs on the old widget can move over one at a time. */
+jsInlineF(
+    "setupGenomeSelector({\n"
+    "    inputId: '%s',\n"
+    "    onSelect: function(item) {\n"
+    "        blatFormSetDb(dbFromRecentItem(item));\n"
+    "    }\n"
+    "});\n"
+    , searchBarId
+);
+
+printf("<div id='blatFormBox'></div>\n");
+printf("</form>\n");
+
+webIncludeResourceFile("hgBlat.css");
+jsIncludeFile("hgBlat.js", NULL);
+
+struct jsonWrite *jw = jsonWriteNew();
+jsonWriteObjectStart(jw, NULL);
+jsonWriteString(jw, "db", trackHubSkipHubName(db));
+jsonWriteString(jw, "organism", trackHubSkipHubName(organism));
+/* Shown as the search bar's own contents rather than in a separate "Current genome:" line: the bar
+ * is wide enough for the full assembly description, and setupGenomeSearchBar keeps it up to date as
+ * the user picks a different one. */
+jsonWriteString(jw, "dbLabel", getCurrentGenomeLabel(db));
+jsonWriteString(jw, "userSeq", cartUsualString(cart, "userSeq", ""));
+blatFormJsonList(jw, "types", typeList, ArraySize(typeList));
+jsonWriteString(jw, "type", cartUsualString(cart, "type", typeList[0]));
+/* Sort and output have no dropdown on this page - hgBlat.js submits them as hidden fields, so the
+ * request is unchanged.  They are pinned to the defaults rather than read from the cart: with no
+ * control to change them, a stale cart value (say output=psl left over from the classic form) would
+ * otherwise be stuck for the rest of the session with no way for the user to get back. */
+jsonWriteString(jw, "sort", pslSortList[0]);      /* "query,score" */
+jsonWriteString(jw, "output", outputList[0]);     /* "hyperlink" */
+jsonWriteBoolean(jw, "allResults", allResults);
+jsonWriteBoolean(jw, "autoRearr", autoRearr);
+jsonWriteBoolean(jw, "allGenomes", allGenomes);
+/* "Keep results" only means something on a machine configured to clear earlier BLAT result tracks.
+ * With blatOldTracks at its "keep" default, or at "hide", there is nothing to opt out of, so the
+ * checkbox is not shown at all.  hgc.c (buildBigPsl) is what acts on blatKeepResults. */
+jsonWriteBoolean(jw, "showKeepResults",
+                 sameString(cfgOptionDefault("blatOldTracks", "keep"), "delete"));
+jsonWriteBoolean(jw, "keepResults", cartUsualBoolean(cart, "blatKeepResults", FALSE));
+/* The example is fetched on demand rather than inlined: it is a realistic ~14 kb sequence, which
+ * would otherwise be embedded in every page load of the form just to serve the few users who click
+ * "Load example". */
+jsonWriteString(jw, "exampleUrl", "../goldenPath/help/blatExample.fa");
+jsonWriteString(jw, "exampleLabel", "Load example - human SOD1 locus");
+/* Same "similar tools" links the classic page offered, so the sidebar isn't a set of dead links.
+ * These carry $DB$ rather than a baked-in db: picking a genome no longer reloads the page, so
+ * blatFormSetDb() re-expands them against the newly chosen assembly.
+ *
+ * Deliberately NOT gated on hgPcrOk() the way the classic form was.  That test can only be made for
+ * the assembly the page happened to load with, so on a page where the genome can be changed without
+ * a round trip it goes stale immediately: loading on an assembly without a PCR server would hide
+ * these links for the rest of the session, even after switching to hg38.  hgPcr reports an
+ * unsupported assembly perfectly well itself, so an occasionally-unsupported link beats a link that
+ * silently disappears. */
+jsonWriteString(jw, "pcrUrlTpl", "../cgi-bin/hgPcr?db=$DB$");
+jsonWriteStringf(jw, "oligoMatchUrlTpl", "hgTrackUi?%s=%s&db=$DB$&g=oligoMatch&oligoMatch=pack",
+                 cartSessionVarName(), cartSessionId(cart));
+/* Link back to the classic form.  blatNewForm is a cart variable (defaulting to the hg.conf
+ * setting), so this is a per-user opt-out rather than a machine-wide switch. */
+jsonWriteStringf(jw, "classicUrl", "hgBlat?blatNewForm=0&%s=%s&db=%s",
+                 cartSessionVarName(), cartSessionId(cart), db);
+jsonWriteObjectEnd(jw);
+jsInlineF("var hgBlatFormData = %s;\n", jw->dy->string);
+jsonWriteFree(&jw);
+}
+
 void hideWeakerOfQueryRcPairs(struct genomeHits* gH1)
 /* hide the weaker of the pair of rc'd query results
  * so users sees only one strand with the best gene hit.
  * Input must be sorted already into the pairs. */
 {
 struct genomeHits* gH2 = NULL;
 for (;gH1; gH1 = gH2->next)
     {
     gH2 = gH1->next;
     if (!gH2)
 	errAbort("Hiding weaker of pairs found one without sibling.");
     if (!((gH1->seqNumber == gH2->seqNumber) && sameString(gH1->db, gH2->db) && (gH1->queryRC != gH2->queryRC)))
 	errAbort("Error matching pairs, sibling does not match seqNumber and db.");
     // check if one or the other had an error
     if (gH1->error && gH2->error)
@@ -2855,35 +3000,59 @@
 
 /* Get sequence - from userSeq variable, or if 
  * that is empty from a file. */
 if (clearUserSeq)
     {
     cartSetString(cart, "userSeq", "");
     cartSetString(cart, "seqFile", "");
     }
 userSeq = cartUsualString(cart, "userSeq", "");
 if (isEmpty(userSeq))
     {
     userSeq = cartOptionalString(cart, "seqFile");
     }
 if (isEmpty(userSeq) || orgChange)
     {
+    /* The JS-built search form is an opt-in replacement for the classic one, controlled by the
+     * blatNewForm cart variable, which defaults to the hg.conf setting of the same name.  Making it
+     * a cart variable (like blatNewPage for the results page) is what lets the new form's banner
+     * offer a working "go back to the original page" link. */
+    boolean newForm = cartUsualBoolean(cart, "blatNewForm",
+				       cfgOptionBooleanDefault("blatNewForm", FALSE));
+    /* The new search form and the new results table are one experience: opting into the new form
+     * also opts into the new results page, so a submission from here never lands back on the
+     * classic results.  Setting it in the cart now (while the form is shown) carries the choice
+     * into the next request, the actual search.  The results page still has its own toggle to
+     * switch back for a given session. */
+    if (newForm)
+	cartSetBoolean(cart, "blatNewPage", TRUE);
+    /* Title from the page design.  The new form names the current assembly in its own genome
+     * picker, so prefixing the title with it as well just repeats it - and for a hub assembly that
+     * prefix is very long ("HG02257.alt.pat.f1_v2 May 2021 BLAT Search").  cartWebStart HTML-escapes
+     * its title, so this has to be a literal em dash, not &mdash;; the page is served as UTF-8 (the
+     * Content-Type header wins over the stale iso-8859-1 <meta>), so the character survives. */
+    if (newForm)
+	cartWebStart(theCart, db, "BLAT — Search the genome for DNA and protein sequence matches");
+    else
 	cartWebStart(theCart, db, "%s BLAT Search", trackHubSkipHubName(organism));
     if (differentString(oldDb, db))
 	printf("<HR><P><EM><B>Note:</B> BLAT search is not available for %s %s; "
 	       "defaulting to %s %s</EM></P><HR>\n",
 	       hGenome(oldDb), hFreezeDate(oldDb), organism, hFreezeDate(db));
+    if (newForm)
+	askForSeqJs(organism, db);
+    else
 	askForSeq(organism, db);
     cartWebEnd();
     }
 else
     {
     if (allGenomes)
 	{
 	cartWebStart(cart, db, "All Genomes BLAT Results");
 
 	struct dbDb *dbList = hGetBlatIndexedDatabases();
 
 	struct dbDb *this = NULL;
 	char *saveDb = db;
 	char *saveOrg = organism;
 	struct sqlConnection *conn = hConnectCentral();