diff --git a/README.md b/README.md index af1c882..f44e5a8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ When a reader is signed in to Datum Cloud, `docs-vars.js` marks these tokens in | `your-org-id` | The reader's org, e.g. `personal-org-2650fdb4` | `--organization`, the org half of `datumctl ctx use ORG/PROJECT` | | `your-project-id` | The reader's project, e.g. `my-api` | `--project`, `PROJECT=` variables, project paths in API URLs, sample output that echoes the project | | `your-email` | The reader's sign-in email | Commands that take the reader's own account, e.g. `datumctl auth switch` | +| `ALB_NAME` | One of the reader's load balancers in the current project, picked on hover when there are several | An existing load balancer: `datumctl alb describe\|logs\|delete\|auth`, `gateway_name=` in metrics | | `default` | Stays `default` (not replaced) | `--namespace`, `NAMESPACE=`. It is the only namespace Datum supports | For example: @@ -76,7 +77,8 @@ Rules: - Don't wrap a token in `<…>`. Shiki reads `<` as a shell redirect and splits the token across several spans, so it can't be replaced. - Replace values only, never variable references: `PROJECT="your-project-id"` uses the token, `--project $PROJECT` stays. - Use `your-email` only for the reader's own account. Someone else's identity (an audit filter, an allowed-users list) keeps an example address like `alice@example.com`. -- Other placeholders (`ALB_NAME`, `INSTANCE_NAME`, `my-zone`, `example.com`) are fine. They are never replaced, so readers still swap them by hand. +- `ALB_NAME` is for a load balancer that already exists. For a name the reader makes up (`datumctl alb create`), write `NEW_ALB_NAME`, which is never replaced. With one load balancer, `ALB_NAME` is filled in. With several, nothing is filled until the reader picks one, and the pick applies to every `ALB_NAME` on the page. +- Other placeholders (`INSTANCE_NAME`, `my-zone`, `example.com`) are fine. They are never replaced, so readers still swap them by hand. - Prose that explains flag syntax, like ``--project ``, can keep the angle brackets. It isn't a value to copy. ## Spellcheck diff --git a/alb/operations/overview.mdx b/alb/operations/overview.mdx index 736a32c..0e61f24 100644 --- a/alb/operations/overview.mdx +++ b/alb/operations/overview.mdx @@ -11,12 +11,12 @@ To install the `datumctl alb` plugin, run `datumctl plugin install alb`. To create a load balancer in front of an origin, run the following command: ```bash -datumctl alb create ALB_NAME --endpoint ORIGIN_URL +datumctl alb create NEW_ALB_NAME --endpoint ORIGIN_URL ``` Replace the following: -- `ALB_NAME`: a name for the load balancer, such as `my-app`. +- `NEW_ALB_NAME`: a name for the load balancer, such as `my-app`. - `ORIGIN_URL`: the `http` or `https` URL of your origin, such as `https://origin.example.com`. The command waits up to 2 minutes for Datum to assign a generated hostname, then prints it: @@ -45,7 +45,7 @@ The following flags change what you create: | `--hostname HOSTNAME` | Attaches a custom hostname at create time. See [Serve traffic on your own domain](/alb/operations/custom-domains). | | `--host-header HOST` | Sets the Host header that Datum sends to the origin. | | `--tls-hostname HOSTNAME` | Sets the hostname that Datum uses to verify TLS when an `--endpoint` origin is an IP address. | -| `--display-name NAME` | Sets the name shown in the Cloud Portal. If you omit `ALB_NAME`, Datum derives one from the display name. | +| `--display-name NAME` | Sets the name shown in the Cloud Portal. If you omit `NEW_ALB_NAME`, Datum derives one from the display name. | | `--dry-run` | Validates the load balancer with the API server without creating it. | For example, to put a load balancer in front of a workload's network service and attach a custom hostname, run the following command: diff --git a/docs-vars.js b/docs-vars.js index 7f9c7e0..1713a82 100644 --- a/docs-vars.js +++ b/docs-vars.js @@ -7,19 +7,22 @@ (function () { // Mirrors PORTAL_URL_BY_HOST in portal-user.js. var PORTAL_URL_BY_HOST = { - 'website.staging.env.datum.net': 'https://cloud.staging.env.datum.net/', + "website.staging.env.datum.net": "https://cloud.staging.env.datum.net/", }; - var portal = new URL(PORTAL_URL_BY_HOST[location.hostname] || 'https://cloud.datum.net/'); + var portal = new URL( + PORTAL_URL_BY_HOST[location.hostname] || "https://cloud.datum.net/", + ); - var VAR_CLASS = 'datum-var'; + var VAR_CLASS = "datum-var"; // A whole token only: `your-project-id-2` is someone's own name, not a token. // `$` and `{` before it are excluded so `${PROJECT_ID}` stays as written. + // `NEW_ALB_NAME` (a name the reader makes up) doesn't match. var TOKEN_PATTERN = - /(? 0) + ); + }; + + var TIP_ID = "datum-var-tip"; // Time to cross the gap from a value to its tooltip before it closes. var TIP_HIDE_DELAY_MS = 150; var TIP_GAP_PX = 8; @@ -79,29 +135,44 @@ // Hover card for a token, after docs.stripe.com/api: which token it is, // the reader's value, then where to change it. var TIP_COPY = { - 'your-org-id': { - label: 'Your organization', - note: 'Switch organizations in the ', - link: { path: '/', label: 'Cloud portal' }, + "your-org-id": { + label: "Your organization", + note: "Switch organizations in the ", + link: { path: "/", label: "Cloud portal" }, }, - 'your-project-id': { - label: 'Your project', + "your-project-id": { + label: "Your project", note: "It's the last project you opened. Open another one in the ", - link: { path: '/', label: 'Cloud portal' }, + link: { path: "/", label: "Cloud portal" }, }, - 'your-email': { - label: 'Your email', - note: 'Manage your account in ', - link: { path: '/account/general', label: 'Account Settings' }, + "your-email": { + label: "Your email", + note: "Manage your account in ", + link: { path: "/account/general", label: "Account Settings" }, + }, + ALB_NAME: { + label: "Your load balancer", + pickLabel: + "Pick your load balancer. It fills every ALB_NAME on this page.", + note: "Manage load balancers in the ", + link: { + path: function (values) { + return ( + "/project/" + encodeURIComponent(values["your-project-id"]) + "/alb" + ); + }, + label: "Cloud portal", + }, }, }; - var buildVar = function (token, value) { - var span = document.createElement('span'); + // The value is read from the shared state when used, not stored on the span: + // picking a load balancer changes it for every `ALB_NAME` at once. + var buildVar = function (token) { + var span = document.createElement("span"); span.className = VAR_CLASS; span.tabIndex = 0; span.dataset.token = token; - span.dataset.value = value; span.textContent = token; return span; }; @@ -109,20 +180,20 @@ // Keeps the original text node in place (trimmed to the text before the // first token) and adds the rest after it, so React still finds the node it // rendered when it updates or removes the code block. - var fillTextNode = function (node, values) { + var fillTextNode = function (node, ctx) { var text = node.nodeValue; var matches = []; var match; TOKEN_PATTERN.lastIndex = 0; while ((match = TOKEN_PATTERN.exec(text))) { - if (values[match[1]]) matches.push(match); + if (isMarked(ctx, match[1])) matches.push(match); } if (!matches.length) return; node.nodeValue = text.slice(0, matches[0].index); var tail = node; matches.forEach(function (m, i) { - var span = buildVar(m[1], values[m[1]]); + var span = buildVar(m[1]); tail.after(span); tail = span; var end = m.index + m[1].length; @@ -135,13 +206,15 @@ }); }; - var fill = function (values) { - document.querySelectorAll('code').forEach(function (code) { + var fill = function (ctx) { + document.querySelectorAll("code").forEach(function (code) { // The hover card names the token in a too; that one stays plain. var text = code.textContent; if ( - (text.indexOf('your-') === -1 && text.indexOf('PROJECT_ID') === -1) || - code.closest('#' + TIP_ID) + (text.indexOf("your-") === -1 && + text.indexOf("PROJECT_ID") === -1 && + text.indexOf(ALB_TOKEN) === -1) || + code.closest("#" + TIP_ID) ) { return; } @@ -149,24 +222,27 @@ var nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(function (node) { - if (!node.parentElement.closest('.' + VAR_CLASS)) fillTextNode(node, values); + if (!node.parentElement.closest("." + VAR_CLASS)) + fillTextNode(node, ctx); }); }); }; - // The code block's text with every token swapped for the reader's value. + // The code block's text with every token swapped for the reader's value. A + // token without one (an `ALB_NAME` not picked yet) stays as written. var filledCode = function (block, values) { - var code = block.querySelector('code') || block; + var code = block.querySelector("code") || block; return code.textContent.replace(TOKEN_PATTERN, function (token) { return values[token] || token; }); }; - var buildCopyButton = function (label, getText) { - var button = document.createElement('button'); - button.type = 'button'; - button.className = 'datum-var-tip-copy'; + var buildCopyButton = function (label, getText, enabled) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "datum-var-tip-copy"; button.textContent = label; + button.disabled = !enabled; var timer = null; var flash = function (text) { button.textContent = text; @@ -175,14 +251,14 @@ button.textContent = label; }, COPIED_LABEL_MS); }; - button.addEventListener('click', function () { + button.addEventListener("click", function () { navigator.clipboard.writeText(getText()).then( function () { - flash('Copied'); + flash("Copied"); }, function () { - flash('Copy failed'); - } + flash("Copy failed"); + }, ); }); return button; @@ -190,45 +266,96 @@ var tipPart = function (tag, className) { var el = document.createElement(tag); - el.className = 'datum-var-tip-' + className; + el.className = "datum-var-tip-" + className; return el; }; - // Header (which token), body (the reader's value and where to change it), - // footer (copy buttons). - var fillTip = function (tip, span, values) { - var copy = TIP_COPY[canonicalToken(span.dataset.token)]; + // One button per load balancer. Nothing is picked by default: on a page like + // `datumctl alb delete ALB_NAME --yes`, a wrong default does real damage. + var buildAlbPicker = function (ctx, onPick) { + var list = tipPart("div", "options"); + list.setAttribute("role", "group"); + list.setAttribute("aria-label", "Your load balancers"); + ctx.albs.forEach(function (alb) { + var option = list.appendChild(tipPart("button", "option")); + option.type = "button"; + option.setAttribute( + "aria-pressed", + String(alb.name === ctx.values[ALB_TOKEN]), + ); + option.appendChild(tipPart("code", "option-name")).textContent = alb.name; + if (alb.displayName && alb.displayName !== alb.name) { + option.appendChild(tipPart("span", "option-display")).textContent = + alb.displayName; + } + option.addEventListener("click", function () { + onPick(alb.name); + }); + }); + return list; + }; + + // Header (which token), body (the reader's value, or the load balancers to + // pick from, and where to change it), footer (copy buttons). + var fillTip = function (tip, span, ctx, onPick) { + var token = span.dataset.token; + var copy = TIP_COPY[canonicalToken(token)]; + var value = ctx.values[token]; + var picking = token === ALB_TOKEN && ctx.albs.length > 1; - var header = tipPart('div', 'header'); - header.appendChild(tipPart('span', 'kicker')).textContent = 'Placeholder'; - header.appendChild(tipPart('code', 'token')).textContent = span.dataset.token; + var header = tipPart("div", "header"); + header.appendChild(tipPart("span", "kicker")).textContent = "Placeholder"; + header.appendChild(tipPart("code", "token")).textContent = token; - var body = tipPart('div', 'body'); - body.appendChild(tipPart('p', 'label')).textContent = copy.label; - body.appendChild(tipPart('code', 'value')).textContent = span.dataset.value; - var note = body.appendChild(tipPart('p', 'note')); + var body = tipPart("div", "body"); + body.appendChild(tipPart("p", "label")).textContent = picking + ? copy.pickLabel + : copy.label; + if (picking) body.appendChild(buildAlbPicker(ctx, onPick)); + else body.appendChild(tipPart("code", "value")).textContent = value; + if (token === ALB_TOKEN && ctx.truncated) { + body.appendChild(tipPart("p", "note")).textContent = + "Showing the first " + ALB_LIST_LIMIT + " load balancers."; + } + var note = body.appendChild(tipPart("p", "note")); note.appendChild(document.createTextNode(copy.note)); - var link = note.appendChild(document.createElement('a')); - link.href = new URL(copy.link.path, portal).href; - link.target = '_blank'; - link.rel = 'noopener'; + var link = note.appendChild(document.createElement("a")); + var linkPath = + typeof copy.link.path === "function" + ? copy.link.path(ctx.values) + : copy.link.path; + link.href = new URL(linkPath, portal).href; + link.target = "_blank"; + link.rel = "noopener"; link.textContent = copy.link.label; - note.appendChild(document.createTextNode('.')); + note.appendChild(document.createTextNode(".")); - var actions = tipPart('div', 'actions'); + // Both buttons wait for a pick when there are several load balancers. + var actions = tipPart("div", "actions"); actions.appendChild( - buildCopyButton('Copy value', function () { - return span.dataset.value; - }) + buildCopyButton( + "Copy value", + function () { + return ctx.values[token]; + }, + Boolean(value), + ), ); // A code block, or inline code like `datumctl ctx use your-org-id/...`. // Skipped when the code is the token alone: it would copy the value again. - var block = span.closest('pre') || span.closest('code'); - if (block && filledCode(block, values).trim() !== span.dataset.value) { + var block = span.closest("pre") || span.closest("code"); + if ( + block && + (block.querySelector("code") || block).textContent.trim() !== token + ) { actions.appendChild( - buildCopyButton('Copy code with my values', function () { - return filledCode(block, values); - }) + buildCopyButton( + "Copy code with my values", + function () { + return filledCode(block, ctx.values); + }, + Boolean(value), + ), ); } @@ -242,22 +369,25 @@ var box = tip.getBoundingClientRect(); var maxLeft = window.innerWidth - box.width - TIP_VIEWPORT_MARGIN_PX; var left = Math.min( - Math.max(anchor.left + anchor.width / 2 - box.width / 2, TIP_VIEWPORT_MARGIN_PX), - maxLeft + Math.max( + anchor.left + anchor.width / 2 - box.width / 2, + TIP_VIEWPORT_MARGIN_PX, + ), + maxLeft, ); var top = anchor.top - box.height - TIP_GAP_PX; if (top < TIP_VIEWPORT_MARGIN_PX) top = anchor.bottom + TIP_GAP_PX; - tip.style.left = left + 'px'; - tip.style.top = top + 'px'; + tip.style.left = left + "px"; + tip.style.top = top + "px"; }; // One tooltip for every value, opened on hover or keyboard focus. It stays // open while the pointer is on it, so its link can be clicked. - var setUpTip = function (values) { - var tip = document.createElement('div'); + var setUpTip = function (ctx) { + var tip = document.createElement("div"); tip.id = TIP_ID; - tip.className = 'datum-var-tip'; - tip.setAttribute('role', 'tooltip'); + tip.className = "datum-var-tip"; + tip.setAttribute("role", "tooltip"); tip.hidden = true; var current = null; var hideTimer = null; @@ -267,7 +397,7 @@ }; var hide = function () { cancelHide(); - if (current) current.removeAttribute('aria-describedby'); + if (current) current.removeAttribute("aria-describedby"); current = null; tip.hidden = true; }; @@ -275,56 +405,75 @@ cancelHide(); hideTimer = setTimeout(hide, TIP_HIDE_DELAY_MS); }; + // The pick applies to every `ALB_NAME` on the page; the card redraws to + // show it and enable the copy buttons, keeping focus on the picked option. + var pick = function (name) { + ctx.values = Object.assign({}, ctx.values, { ALB_NAME: name }); + if (!current) return; + fillTip(tip, current, ctx, pick); + placeTip(tip, current); + var picked = tip.querySelector('[aria-pressed="true"]'); + if (picked) picked.focus(); + }; var show = function (span) { cancelHide(); if (current === span) return; - if (current) current.removeAttribute('aria-describedby'); + if (current) current.removeAttribute("aria-describedby"); current = span; if (!tip.isConnected) document.body.appendChild(tip); - fillTip(tip, span, values); + fillTip(tip, span, ctx, pick); tip.hidden = false; placeTip(tip, span); - span.setAttribute('aria-describedby', TIP_ID); + span.setAttribute("aria-describedby", TIP_ID); }; var varFrom = function (target) { - return target instanceof Element ? target.closest('.' + VAR_CLASS) : null; + return target instanceof Element ? target.closest("." + VAR_CLASS) : null; }; - document.addEventListener('mouseover', function (e) { + document.addEventListener("mouseover", function (e) { var span = varFrom(e.target); if (span) show(span); else if (current && tip.contains(e.target)) cancelHide(); }); - document.addEventListener('mouseout', function (e) { + document.addEventListener("mouseout", function (e) { if (!current) return; var from = varFrom(e.target) || (tip.contains(e.target) ? tip : null); if (!from || from.contains(e.relatedTarget)) return; hideSoon(); }); - document.addEventListener('focusin', function (e) { + document.addEventListener("focusin", function (e) { var span = varFrom(e.target); if (span) show(span); else if (!tip.contains(e.target)) hide(); }); - document.addEventListener('keydown', function (e) { - if (e.key === 'Escape' && current) hide(); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && current) hide(); }); // The value moves under a fixed tooltip on scroll, including the code - // block's own horizontal scroll. - window.addEventListener('scroll', hide, true); - window.addEventListener('resize', hide); + // block's own horizontal scroll. Scrolling the card's own list doesn't. + window.addEventListener( + "scroll", + function (e) { + if (!(e.target instanceof Node && tip.contains(e.target))) hide(); + }, + true, + ); + window.addEventListener("resize", hide); }; window.datumSessionStatus.then(function (status) { var values = valuesFrom(status); if (!Object.keys(values).length) return; - setUpTip(values); + // Shared by every marked token and the hover card. `values` is replaced, + // never edited, when the reader picks a load balancer. + var ctx = { values: values, albs: [], truncated: false }; + setUpTip(ctx); // Mintlify renders code and swaps page content on client-side navigation, // so this runs on every DOM change. Marked tokens are skipped, so a pass // that has nothing to do changes nothing. var apply = function () { - fill(values); + fill(ctx); }; apply(); @@ -332,5 +481,21 @@ childList: true, subtree: true, }); + + // The other tokens are already marked; `ALB_NAME` follows when its list + // arrives. A single load balancer is filled in; several wait for a pick. + var project = values["your-project-id"]; + if (!project) return; + fetchAlbs(project).then(function (albs) { + if (!albs.items.length) return; + ctx.albs = albs.items; + ctx.truncated = albs.truncated; + if (albs.items.length === 1) { + ctx.values = Object.assign({}, ctx.values, { + ALB_NAME: albs.items[0].name, + }); + } + apply(); + }); }); })(); diff --git a/style.css b/style.css index 4e2efbe..0c1a4ec 100644 --- a/style.css +++ b/style.css @@ -167,47 +167,91 @@ a.nav-anchor { /* Active nav state */ #navigation-items li[data-active="true"] > a { - background-color: #4D6356 !important; - color: #E6F59F !important; + background-color: #4d6356 !important; + color: #e6f59f !important; text-shadow: none !important; } .dark #navigation-items li[data-active="true"] > a { - background-color: #E6F59F !important; - color: #4D6356 !important; + background-color: #e6f59f !important; + color: #4d6356 !important; text-shadow: none !important; } /* Cards: subtle Datum-palette tint on every card. Cards in a group cycle rose, green, lime, slate; className="dc-" picks one explicitly. :where() keeps the defaults at zero specificity so dc-* classes win. */ .card { - background-image: linear-gradient(135deg, var(--dc-tint) 0%, transparent 65%) !important; + background-image: linear-gradient( + 135deg, + var(--dc-tint) 0%, + transparent 65% + ) !important; border-color: var(--dc-edge) !important; } .card [data-component-part="card-icon"] svg { background-color: var(--dc-icon) !important; } :where(.card), -:where(.card-group > .card:nth-child(4n+1)), -.dc-rose { --dc-tint: rgba(191, 149, 149, 0.22); --dc-edge: rgba(191, 149, 149, 0.45); --dc-icon: #BF9595; } -:where(.card-group > .card:nth-child(4n+2)), -.dc-green { --dc-tint: rgba(77, 99, 86, 0.20); --dc-edge: rgba(77, 99, 86, 0.45); --dc-icon: #4D6356; } -:where(.card-group > .card:nth-child(4n+3)), -.dc-lime { --dc-tint: rgba(230, 245, 159, 0.45); --dc-edge: rgba(190, 205, 110, 0.55); --dc-icon: #8A9A2E; } +:where(.card-group > .card:nth-child(4n + 1)), +.dc-rose { + --dc-tint: rgba(191, 149, 149, 0.22); + --dc-edge: rgba(191, 149, 149, 0.45); + --dc-icon: #bf9595; +} +:where(.card-group > .card:nth-child(4n + 2)), +.dc-green { + --dc-tint: rgba(77, 99, 86, 0.2); + --dc-edge: rgba(77, 99, 86, 0.45); + --dc-icon: #4d6356; +} +:where(.card-group > .card:nth-child(4n + 3)), +.dc-lime { + --dc-tint: rgba(230, 245, 159, 0.45); + --dc-edge: rgba(190, 205, 110, 0.55); + --dc-icon: #8a9a2e; +} :where(.card-group > .card:nth-child(4n)), -.dc-slate { --dc-tint: rgba(12, 29, 49, 0.12); --dc-edge: rgba(12, 29, 49, 0.25); --dc-icon: #3B5B82; } -.dc-red { --dc-tint: rgba(196, 64, 52, 0.18); --dc-edge: rgba(196, 64, 52, 0.45); --dc-icon: #C44034; } +.dc-slate { + --dc-tint: rgba(12, 29, 49, 0.12); + --dc-edge: rgba(12, 29, 49, 0.25); + --dc-icon: #3b5b82; +} +.dc-red { + --dc-tint: rgba(196, 64, 52, 0.18); + --dc-edge: rgba(196, 64, 52, 0.45); + --dc-icon: #c44034; +} :where(.dark .card), -:where(.dark .card-group > .card:nth-child(4n+1)), -.dark .dc-rose { --dc-tint: rgba(191, 149, 149, 0.20); --dc-edge: rgba(191, 149, 149, 0.40); --dc-icon: #D4A9A9; } -:where(.dark .card-group > .card:nth-child(4n+2)), -.dark .dc-green { --dc-tint: rgba(110, 145, 125, 0.22); --dc-edge: rgba(110, 145, 125, 0.45); --dc-icon: #8FB09E; } -:where(.dark .card-group > .card:nth-child(4n+3)), -.dark .dc-lime { --dc-tint: rgba(230, 245, 159, 0.14); --dc-edge: rgba(230, 245, 159, 0.35); --dc-icon: #E6F59F; } +:where(.dark .card-group > .card:nth-child(4n + 1)), +.dark .dc-rose { + --dc-tint: rgba(191, 149, 149, 0.2); + --dc-edge: rgba(191, 149, 149, 0.4); + --dc-icon: #d4a9a9; +} +:where(.dark .card-group > .card:nth-child(4n + 2)), +.dark .dc-green { + --dc-tint: rgba(110, 145, 125, 0.22); + --dc-edge: rgba(110, 145, 125, 0.45); + --dc-icon: #8fb09e; +} +:where(.dark .card-group > .card:nth-child(4n + 3)), +.dark .dc-lime { + --dc-tint: rgba(230, 245, 159, 0.14); + --dc-edge: rgba(230, 245, 159, 0.35); + --dc-icon: #e6f59f; +} :where(.dark .card-group > .card:nth-child(4n)), -.dark .dc-slate { --dc-tint: rgba(120, 160, 210, 0.16); --dc-edge: rgba(120, 160, 210, 0.35); --dc-icon: #8FB0D8; } -.dark .dc-red { --dc-tint: rgba(230, 100, 88, 0.18); --dc-edge: rgba(230, 100, 88, 0.45); --dc-icon: #E66458; } +.dark .dc-slate { + --dc-tint: rgba(120, 160, 210, 0.16); + --dc-edge: rgba(120, 160, 210, 0.35); + --dc-icon: #8fb0d8; +} +.dark .dc-red { + --dc-tint: rgba(230, 100, 88, 0.18); + --dc-edge: rgba(230, 100, 88, 0.45); + --dc-icon: #e66458; +} /* Signed-in user menu in navbar (portal-user.js), styled after datum.net */ .portal-user { @@ -297,7 +341,9 @@ a.nav-anchor { margin-top: 8px; background-color: #fff; border: 1px solid #c3c6c9; - box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.05), 1px 8px 8px 0 rgba(0, 0, 0, 0.02); + box-shadow: + 0 4px 16px 0 rgba(0, 0, 0, 0.05), + 1px 8px 8px 0 rgba(0, 0, 0, 0.02); } .portal-user-menu[hidden] { @@ -441,7 +487,9 @@ html.dark .shiki span.datum-var { color: var(--tip-fg); background-color: var(--tip-bg); border: 1px solid var(--tip-border); - box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.05), 1px 8px 8px 0 rgba(0, 0, 0, 0.02); + box-shadow: + 0 4px 16px 0 rgba(0, 0, 0, 0.05), + 1px 8px 8px 0 rgba(0, 0, 0, 0.02); } .dark .datum-var-tip { @@ -544,11 +592,73 @@ html.dark .shiki span.datum-var { border: 1px solid var(--tip-border); } -.datum-var-tip-copy:hover, +.datum-var-tip-copy:hover:not(:disabled), .datum-var-tip-copy:focus-visible { background-color: var(--tip-subtle); } +/* Disabled until the reader picks a load balancer (ALB_NAME). */ +.datum-var-tip-copy:disabled { + color: var(--tip-muted); + cursor: not-allowed; + opacity: 0.6; +} + +/* Load balancers to pick from when ALB_NAME has several. Scrolls past ~6. */ +.datum-var-tip-options { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 220px; + margin-top: 6px; + overflow-y: auto; +} + +.datum-var-tip-option { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 4px 32px 4px 10px; + text-align: left; + color: var(--tip-fg); + border: 1px solid var(--tip-border); +} + +.datum-var-tip-option:hover, +.datum-var-tip-option:focus-visible { + background-color: var(--tip-subtle); +} + +/* The picked one: a heavier border and a check mark. aria-pressed already + tells screen readers, so the mark has empty alt text. */ +.datum-var-tip-option[aria-pressed="true"] { + border-color: var(--tip-fg); + box-shadow: inset 0 0 0 1px var(--tip-fg); + background-color: var(--tip-subtle); +} + +.datum-var-tip-option[aria-pressed="true"]::after { + content: "✓" / ""; + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + font-size: 0.875rem; + font-weight: 700; + line-height: 1; +} + +#datum-var-tip .datum-var-tip-option-name { + font-weight: 600; + background-color: transparent; +} + +.datum-var-tip-option-display { + font-size: 0.75rem; + color: var(--tip-muted); +} + /* Navbar right: theme selector first, bolder; "Status" last (Polylane-style) */ div:has(> #theme-preference-menu-trigger) { order: -1;