diff --git a/enterprise/e2e/html/hurl/schemas-as.all.hurl b/enterprise/e2e/html/hurl/schemas-as.all.hurl
index 5e6530035..43af1c9a7 100644
--- a/enterprise/e2e/html/hurl/schemas-as.all.hurl
+++ b/enterprise/e2e/html/hurl/schemas-as.all.hurl
@@ -719,3 +719,73 @@ header "Vary" == "Accept, Accept-Encoding"
header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
body contains "Draft3"
body contains "https://www.learnjsonschema.com/draft3"
+
+# The web page offers every conversion the metadata advertises, oldest first
+GET {{base}}/test/draft3/string
+Accept: text/html
+HTTP 200
+Cache-Control: public, max-age=0, must-revalidate
+Content-Type: text/html; charset=utf-8
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: frame-ancestors 'none'
+X-Frame-Options: DENY
+[Asserts]
+header "Vary" == "Accept, Accept-Encoding"
+header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
+xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle])" == "Convert"
+xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle]/@aria-expanded)" == "false"
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 5
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "Draft4"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/draft3/string.json?as=draft4"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a)" == "Draft6"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a/@href)" == "/test/draft3/string.json?as=draft6"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[3]/a)" == "Draft7"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[3]/a/@href)" == "/test/draft3/string.json?as=draft7"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[4]/a)" == "2019-09"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[4]/a/@href)" == "/test/draft3/string.json?as=2019-09"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a)" == "2020-12"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a/@href)" == "/test/draft3/string.json?as=2020-12"
+
+# The dialect the schema already declares is left out, as a schema is
+# already written in it
+GET {{base}}/test/jsonld/draft7
+Accept: text/html
+HTTP 200
+Cache-Control: public, max-age=0, must-revalidate
+Content-Type: text/html; charset=utf-8
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: frame-ancestors 'none'
+X-Frame-Options: DENY
+[Asserts]
+header "Vary" == "Accept, Accept-Encoding"
+header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
+xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle])" == "Convert"
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 2
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "2019-09"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/jsonld/draft7.json?as=2019-09"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a)" == "2020-12"
+xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a/@href)" == "/test/jsonld/draft7.json?as=2020-12"
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a[text()='Draft7'])" == 0
+
+# A schema on the newest dialect has nothing left to be offered, even though
+# the API still serves it in that dialect
+GET {{base}}/test/object
+Accept: text/html
+HTTP 200
+Cache-Control: public, max-age=0, must-revalidate
+Content-Type: text/html; charset=utf-8
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: frame-ancestors 'none'
+X-Frame-Options: DENY
+[Asserts]
+header "Vary" == "Accept, Accept-Encoding"
+header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
+xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0
+xpath "boolean(//a[@href='/test/object.json?bundle=1'])" == true
+
+GET {{base}}/test/object.json?as=2020-12
+HTTP 200
+Content-Type: application/schema+json
+[Asserts]
+header "ETag" exists
diff --git a/enterprise/e2e/html/playwright/convert.spec.js b/enterprise/e2e/html/playwright/convert.spec.js
new file mode 100644
index 000000000..ee670fa31
--- /dev/null
+++ b/enterprise/e2e/html/playwright/convert.spec.js
@@ -0,0 +1,200 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Schema Convert Dropdown', () => {
+ test('starts collapsed and lists every dialect the schema can be asked for',
+ async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+
+ // The menu is closed until asked for
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(menu).not.toBeVisible();
+
+ await toggle.click();
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ await expect(menu).toBeVisible();
+
+ // A Draft 3 schema can be asked for in every newer official dialect
+ const items = menu.locator('a');
+ await expect(items).toHaveText([
+ 'Draft4', 'Draft6', 'Draft7', '2019-09', '2020-12'
+ ]);
+ await expect(items.nth(0)).toHaveAttribute(
+ 'href', '/test/draft3/string.json?as=draft4');
+ await expect(items.nth(1)).toHaveAttribute(
+ 'href', '/test/draft3/string.json?as=draft6');
+ await expect(items.nth(2)).toHaveAttribute(
+ 'href', '/test/draft3/string.json?as=draft7');
+ await expect(items.nth(3)).toHaveAttribute(
+ 'href', '/test/draft3/string.json?as=2019-09');
+ await expect(items.nth(4)).toHaveAttribute(
+ 'href', '/test/draft3/string.json?as=2020-12');
+ });
+
+ test('leaves out the dialect the schema already declares',
+ async ({ page }) => {
+ await page.goto('/test/jsonld/draft7');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+
+ const items = menu.locator('a');
+ await expect(items).toHaveText([ '2019-09', '2020-12' ]);
+ await expect(items.nth(0)).toHaveAttribute(
+ 'href', '/test/jsonld/draft7.json?as=2019-09');
+ await expect(items.nth(1)).toHaveAttribute(
+ 'href', '/test/jsonld/draft7.json?as=2020-12');
+ });
+
+ test('a schema on the newest dialect gets no control at all',
+ async ({ page }) => {
+ await page.goto('/test/object');
+
+ await expect(page.locator('[data-sourcemeta-ui-dropdown-toggle]'))
+ .toHaveCount(0);
+ await expect(page.locator('a[href="/test/object.json?bundle=1"]'))
+ .toBeVisible();
+ });
+
+ test('the menu sits below the button it belongs to', async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+
+ const toggleBox = await toggle.boundingBox();
+ const menuBox = await menu.boundingBox();
+ expect(menuBox.y).toBeGreaterThanOrEqual(toggleBox.y + toggleBox.height);
+ expect(menuBox.x).toBe(toggleBox.x);
+ });
+
+ test('clicking outside the dropdown closes it', async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+ await expect(menu).toBeVisible();
+
+ await page.locator('footer').click();
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(menu).not.toBeVisible();
+ await expect(toggle).not.toBeFocused();
+ });
+
+ test('pressing escape from inside the menu hands focus back to the button',
+ async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+ await page.keyboard.press('Tab');
+ await expect(menu.locator('a').first()).toBeFocused();
+
+ await page.keyboard.press('Escape');
+
+ await expect(menu).not.toBeVisible();
+ await expect(toggle).toBeFocused();
+ });
+
+ test('tabbing past the last entry closes it and keeps going',
+ async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+
+ // Walk out of the menu through its five entries
+ for (let index = 0; index < 6; index++) {
+ await page.keyboard.press('Tab');
+ }
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(menu).not.toBeVisible();
+
+ // Whatever follows the menu is where the walk was headed
+ await expect(page.locator('[data-sourcemeta-ui-tab-target="usage-cli"]'))
+ .toBeFocused();
+ await expect(toggle).not.toBeFocused();
+ });
+
+ test('moving focus out of an entry lands where it was aimed',
+ async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ const bundle = page.locator('a[href="/test/draft3/string.json?bundle=1"]');
+ await toggle.click();
+ await page.keyboard.press('Tab');
+ await expect(menu.locator('a').first()).toBeFocused();
+
+ await bundle.focus();
+
+ await expect(menu).not.toBeVisible();
+ await expect(bundle).toBeFocused();
+ await expect(toggle).not.toBeFocused();
+ });
+
+ test('clicking the empty part of the menu leaves it open',
+ async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+
+ const box = await menu.boundingBox();
+ await page.mouse.click(box.x + box.width - 2, box.y + box.height - 2);
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ await expect(menu).toBeVisible();
+ });
+
+ test('an entry navigates to the converted schema', async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+ await menu.locator('a', { hasText: '2020-12' }).click();
+
+ await expect(page).toHaveURL('/test/draft3/string.json?as=2020-12');
+ });
+
+ test('pressing escape closes it', async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+ await expect(menu).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(menu).not.toBeVisible();
+ });
+
+ test('clicking the button again closes it', async ({ page }) => {
+ await page.goto('/test/draft3/string');
+
+ const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]');
+ const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]');
+ await toggle.click();
+ await expect(menu).toBeVisible();
+
+ await toggle.click();
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(menu).not.toBeVisible();
+ });
+});
diff --git a/enterprise/e2e/html/playwright/playwright.config.js b/enterprise/e2e/html/playwright/playwright.config.js
new file mode 100644
index 000000000..566a8c968
--- /dev/null
+++ b/enterprise/e2e/html/playwright/playwright.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from '@playwright/test';
+
+// See https://playwright.dev/docs/test-configuration
+export default defineConfig({
+ testDir: '.',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'list',
+ outputDir: '../../../../build/test-results',
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL,
+ trace: 'on-first-retry'
+ }
+});
diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt
index 2538054fb..398080437 100644
--- a/src/web/CMakeLists.txt
+++ b/src/web/CMakeLists.txt
@@ -22,6 +22,7 @@ sourcemeta_esbuild_bundle(
DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/main.js"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/copy.js"
+ "${CMAKE_CURRENT_SOURCE_DIR}/scripts/dropdown.js"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/search.js"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/tabs.js"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/editor.js"
diff --git a/src/web/helpers.h b/src/web/helpers.h
index ec430f5f8..f4a4159fe 100644
--- a/src/web/helpers.h
+++ b/src/web/helpers.h
@@ -95,62 +95,72 @@ make_schema_health_progress_bar(sourcemeta::core::HTMLWriter &writer,
writer.close();
}
-inline auto
-make_dialect_badge(sourcemeta::core::HTMLWriter &writer,
- const sourcemeta::core::JSON::String &base_dialect_uri)
- -> void {
- const auto [short_name, is_current] =
- [&base_dialect_uri]() -> std::pair {
- if (base_dialect_uri == "https://json-schema.org/draft/2020-12/schema" ||
- base_dialect_uri ==
- "https://json-schema.org/draft/2020-12/hyper-schema") {
- return {"2020-12", true};
- }
+inline auto dialect_display_name(const std::string_view name) -> std::string {
+ std::string result{name};
+ if (!result.empty()) {
+ result.front() = static_cast(
+ std::toupper(static_cast(result.front())));
+ }
- if (base_dialect_uri == "https://json-schema.org/draft/2019-09/schema" ||
- base_dialect_uri ==
- "https://json-schema.org/draft/2019-09/hyper-schema") {
- return {"2019-09", false};
- }
+ return result;
+}
- if (base_dialect_uri == "http://json-schema.org/draft-07/schema#" ||
- base_dialect_uri == "http://json-schema.org/draft-07/hyper-schema#") {
- return {"draft7", false};
- }
+// The short name a dialect goes by, which is also the name a conversion into
+// it is asked for by
+inline auto
+dialect_short_name(const sourcemeta::core::JSON::String &base_dialect_uri)
+ -> std::string_view {
+ if (base_dialect_uri == "https://json-schema.org/draft/2020-12/schema" ||
+ base_dialect_uri ==
+ "https://json-schema.org/draft/2020-12/hyper-schema") {
+ return "2020-12";
+ }
- if (base_dialect_uri == "http://json-schema.org/draft-06/schema#" ||
- base_dialect_uri == "http://json-schema.org/draft-06/hyper-schema#") {
- return {"draft6", false};
- }
+ if (base_dialect_uri == "https://json-schema.org/draft/2019-09/schema" ||
+ base_dialect_uri ==
+ "https://json-schema.org/draft/2019-09/hyper-schema") {
+ return "2019-09";
+ }
- if (base_dialect_uri == "http://json-schema.org/draft-04/schema#" ||
- base_dialect_uri == "http://json-schema.org/draft-04/hyper-schema#") {
- return {"draft4", false};
- }
+ if (base_dialect_uri == "http://json-schema.org/draft-07/schema#" ||
+ base_dialect_uri == "http://json-schema.org/draft-07/hyper-schema#") {
+ return "draft7";
+ }
- if (base_dialect_uri == "http://json-schema.org/draft-03/schema#" ||
- base_dialect_uri == "http://json-schema.org/draft-03/hyper-schema#") {
- return {"draft3", false};
- }
+ if (base_dialect_uri == "http://json-schema.org/draft-06/schema#" ||
+ base_dialect_uri == "http://json-schema.org/draft-06/hyper-schema#") {
+ return "draft6";
+ }
- return {"unknown", false};
- }();
+ if (base_dialect_uri == "http://json-schema.org/draft-04/schema#" ||
+ base_dialect_uri == "http://json-schema.org/draft-04/hyper-schema#") {
+ return "draft4";
+ }
- // Capitalize first character
- std::string display_name = short_name;
- if (!display_name.empty()) {
- display_name[0] = static_cast(
- std::toupper(static_cast(display_name[0])));
+ if (base_dialect_uri == "http://json-schema.org/draft-03/schema#" ||
+ base_dialect_uri == "http://json-schema.org/draft-03/hyper-schema#") {
+ return "draft3";
}
+ return "unknown";
+}
+
+inline auto
+make_dialect_badge(sourcemeta::core::HTMLWriter &writer,
+ const sourcemeta::core::JSON::String &base_dialect_uri)
+ -> void {
+ const auto short_name{dialect_short_name(base_dialect_uri)};
+ const auto is_current{short_name == "2020-12"};
+
writer.a()
- .attribute("href", "https://www.learnjsonschema.com/" + short_name)
+ .attribute("href",
+ "https://www.learnjsonschema.com/" + std::string{short_name})
.attribute("target", "_blank");
writer.span().attribute(
"class",
"align-middle badge " +
std::string(is_current ? "text-bg-primary" : "text-bg-danger"));
- writer.text(display_name);
+ writer.text(dialect_display_name(short_name));
writer.close();
writer.close();
}
diff --git a/src/web/pages/schema.cc b/src/web/pages/schema.cc
index 977dc0b05..e99732227 100644
--- a/src/web/pages/schema.cc
+++ b/src/web/pages/schema.cc
@@ -8,6 +8,7 @@
#include
#include
+#include // std::ranges::any_of
#include // assert
#include // std::chrono
#include // std::filesystem
@@ -81,6 +82,48 @@ auto GenerateWebSchema::handler(
.attribute("role", "button");
body.text("Bundle");
body.close();
+
+ // A schema is already written in the dialect it declares, so offering
+ // to convert it into that one reads as a choice where there is none
+ const auto *const conversions{meta.try_at("conversions")};
+ const auto declared{
+ html::dialect_short_name(meta.at("baseDialect").to_string())};
+ if (conversions != nullptr &&
+ std::ranges::any_of(conversions->as_object(),
+ [&declared](const auto &conversion) {
+ return conversion.first != declared;
+ })) {
+ body.div()
+ .attribute("class", "btn-group ms-2")
+ .attribute("data-sourcemeta-ui-dropdown", "");
+ body.button()
+ .attribute("class", "btn btn-secondary dropdown-toggle")
+ .attribute("type", "button")
+ .attribute("aria-expanded", "false")
+ .attribute("data-sourcemeta-ui-dropdown-toggle", "");
+ body.text("Convert");
+ body.close();
+ body.ul()
+ .attribute("class", "dropdown-menu")
+ .attribute("data-sourcemeta-ui-dropdown-menu", "");
+ for (const auto &conversion : conversions->as_object()) {
+ if (conversion.first == declared) {
+ continue;
+ }
+
+ body.li();
+ body.a()
+ .attribute("class", "dropdown-item")
+ .attribute("href", meta.at("path").to_string() +
+ ".json?as=" + conversion.first);
+ body.text(html::dialect_display_name(conversion.first));
+ body.close();
+ body.close();
+ }
+ body.close();
+ body.close();
+ }
+
body.close();
// Integration snippets
diff --git a/src/web/scripts/dropdown.js b/src/web/scripts/dropdown.js
new file mode 100644
index 000000000..26b3f3d23
--- /dev/null
+++ b/src/web/scripts/dropdown.js
@@ -0,0 +1,48 @@
+document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => {
+ const toggle = dropdown.querySelector("[data-sourcemeta-ui-dropdown-toggle]");
+ const menu = dropdown.querySelector("[data-sourcemeta-ui-dropdown-menu]");
+
+ function close() {
+ menu.classList.remove("show");
+ toggle.setAttribute("aria-expanded", "false");
+ }
+
+ // Whoever was reading the menu would be left standing on something that is
+ // no longer there, with no way back to the control that opened it
+ function dismiss() {
+ const wasInside = dropdown.contains(document.activeElement);
+ close();
+ if (wasInside) {
+ toggle.focus();
+ }
+ }
+
+ toggle.addEventListener("click", () => {
+ if (toggle.getAttribute("aria-expanded") === "true") {
+ close();
+ } else {
+ menu.classList.add("show");
+ toggle.setAttribute("aria-expanded", "true");
+ }
+ });
+
+ document.addEventListener("click", (event) => {
+ if (!dropdown.contains(event.target)) {
+ close();
+ }
+ });
+
+ // Focus landing nowhere is focus that never went anywhere, such as a click
+ // on a part of the menu that cannot hold it
+ dropdown.addEventListener("focusout", (event) => {
+ if (event.relatedTarget && !dropdown.contains(event.relatedTarget)) {
+ close();
+ }
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ dismiss();
+ }
+ });
+});
diff --git a/src/web/scripts/main.js b/src/web/scripts/main.js
index 17d78a1d0..1dc8b2536 100644
--- a/src/web/scripts/main.js
+++ b/src/web/scripts/main.js
@@ -1,5 +1,6 @@
import "./tabs.js";
import "./copy.js";
+import "./dropdown.js";
import "./search.js";
import "./dependencies.js";
diff --git a/src/web/style.scss b/src/web/style.scss
index 59b29a19b..e648bf1c9 100644
--- a/src/web/style.scss
+++ b/src/web/style.scss
@@ -27,7 +27,7 @@ $breadcrumb-font-size: 15px;
@import "../../vendor/bootstrap/scss/forms";
@import "../../vendor/bootstrap/scss/buttons";
// @import "../../vendor/bootstrap/scss/transitions";
-// @import "../../vendor/bootstrap/scss/dropdown";
+@import "../../vendor/bootstrap/scss/dropdown";
@import "../../vendor/bootstrap/scss/button-group";
@import "../../vendor/bootstrap/scss/nav";
@import "../../vendor/bootstrap/scss/navbar";
@@ -57,6 +57,14 @@ $bootstrap-icons-font: bootstrap-icons;
$bootstrap-icons-font-dir: ".";
@import "../../vendor/bootstrap-icons/font/bootstrap-icons.scss";
+// Bootstrap only places `.dropdown-menu` when Popper has marked it, and this
+// UI drives its dropdowns without Popper
+.dropdown-menu {
+ top: 100%;
+ left: 0;
+ margin-top: $dropdown-spacer;
+}
+
// Bootstrap doesn't seem to provide breakpoint-based modifiers to `.w-*`
@include media-breakpoint-up(md) {
.w-md-auto {
diff --git a/test/e2e/html/hurl/html.all.hurl b/test/e2e/html/hurl/html.all.hurl
index 6672032c2..cc8badf73 100644
--- a/test/e2e/html/hurl/html.all.hurl
+++ b/test/e2e/html/hurl/html.all.hurl
@@ -607,3 +607,20 @@ Cache-Control: no-store
[Asserts]
header "Vary" not exists
jsonpath "$.valid" == true
+
+# A schema with no conversions on offer grows no conversion control. This
+# schema is a meta-schema, which is pinned where its metadata is asserted
+GET {{base}}/test/schemas/custom-meta-draft7
+Accept: text/html
+HTTP 200
+Cache-Control: public, max-age=0, must-revalidate
+Content-Type: text/html; charset=utf-8
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: frame-ancestors 'none'
+X-Frame-Options: DENY
+[Asserts]
+header "Vary" == "Accept, Accept-Encoding"
+header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
+xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0
+xpath "boolean(//a[@href='/test/schemas/custom-meta-draft7.json?bundle=1'])" == true
diff --git a/test/e2e/html/hurl/schemas-as.community.hurl b/test/e2e/html/hurl/schemas-as.community.hurl
index f52c5864c..693b88926 100644
--- a/test/e2e/html/hurl/schemas-as.community.hurl
+++ b/test/e2e/html/hurl/schemas-as.community.hurl
@@ -170,3 +170,20 @@ header "Content-Security-Policy" not exists
header "X-Frame-Options" not exists
header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
jsonpath "$.valid" == true
+
+# With no conversions on offer at all, the web page grows no conversion
+# control, however convertible the schema itself looks
+GET {{base}}/test/v2.0/schema
+Accept: text/html
+HTTP 200
+Cache-Control: public, max-age=0, must-revalidate
+Content-Type: text/html; charset=utf-8
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: frame-ancestors 'none'
+X-Frame-Options: DENY
+[Asserts]
+header "Vary" == "Accept, Accept-Encoding"
+header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
+xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0
+xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0
+xpath "boolean(//a[@href='/test/v2.0/schema.json?bundle=1'])" == true