Public: anyone can read this; the crew can change it.
Design: /map's lanes use §5's list and row, and the map-lane-* classes leave §5d #3662
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3651-design-map-s-lanes-use-5-s-list-and-row → main 3 commits fork
6 files
changed +78
−77
docs/DESIGN.md +2 −2
| @@ -157,7 +157,7 @@ specification. | ||
| 157 | 157 | | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` | |
| 158 | 158 | | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name | |
| 159 | 159 | | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar | |
| 160 | | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta | | |
| 160 | | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `fold` makes the row a `<details>`'s `<summary>`, its lead the caret turned when open (/map's lanes); `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta | | |
| 161 | 161 | | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line | |
| 162 | 162 | | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry | |
| 163 | 163 | | **table** | `.table` | `data-dense`; `.num` cells right-aligned | — | aligned columns | |
| @@ -396,7 +396,7 @@ gone, so this list only shrinks. | ||
| 396 | 396 | - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits` |
| 397 | 397 | - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals` |
| 398 | 398 | - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out` |
| 399 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` | |
| 399 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` | |
| 400 | 400 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` |
| 401 | 401 | |
| 402 | 402 | --- |
src/web/pages/map.ts +49 −68
| @@ -57,7 +57,7 @@ import { | ||
| 57 | 57 | import { liveRegionsJs } from "../client.ts"; |
| 58 | 58 | import { html, type Html } from "../html.ts"; |
| 59 | 59 | import { page } from "../layout.ts"; |
| 60 | import { avatar, icon } from "../ui.ts"; | |
| 60 | import { avatar, badge, icon, listCard, row } from "../ui.ts"; | |
| 61 | 61 | import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts"; |
| 62 | 62 | import { |
| 63 | 63 | parkedTwice, |
| @@ -878,12 +878,12 @@ export function countWords(words: readonly Word[]): Record<Word, number> { | ||
| 878 | 878 | |
| 879 | 879 | function countsLine(counts: Record<Word, number>): Html { |
| 880 | 880 | const always = new Set<Word>(["ready", "working", "blocked", "done"]); |
| 881 | return html`<p class="map-counts"> | |
| 881 | return html`<span class="map-counts"> | |
| 882 | 882 | ${WORDS.filter((w) => always.has(w) || counts[w] > 0).map( |
| 883 | 883 | (w) => |
| 884 | 884 | html`<span><i data-state="${w}"></i>${w} <b>${counts[w]}</b></span>`, |
| 885 | 885 | )} |
| 886 | </p>`; | |
| 886 | </span>`; | |
| 887 | 887 | } |
| 888 | 888 | |
| 889 | 889 | function rankedList( |
| @@ -1123,32 +1123,17 @@ export const mapCss = /* css */ ` | ||
| 1123 | 1123 | .map-pillars ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 14px; } |
| 1124 | 1124 | .map-empty { display: grid; justify-items: center; gap: 8px; padding: 40px 16px; border: 1px dashed var(--border); border-radius: var(--radius); color: var(--muted-fg); text-align: center; } |
| 1125 | 1125 | |
| 1126 | /* ---- lanes ---- */ | |
| 1127 | .map-lanes { display: grid; gap: 14px; margin-top: 16px; } | |
| 1128 | .map-lane { border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); box-shadow: var(--shadow-1); } | |
| 1129 | .map-lane-head { display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; padding: 10px 14px; cursor: pointer; list-style: none; } | |
| 1130 | .map-lane-head::-webkit-details-marker { display: none; } | |
| 1131 | .map-lane-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius); } | |
| 1132 | .map-lane-caret { display: inline-flex; color: var(--faint); transition: transform 160ms var(--ease); } | |
| 1133 | .map-lane[open] > .map-lane-head .map-lane-caret { transform: rotate(90deg); } | |
| 1134 | .map-lane-name { font-weight: 620; font-size: 15px; letter-spacing: -0.01em; } | |
| 1135 | .map-lane-name .muted { font-weight: 500; } | |
| 1136 | .map-lane-word { font-size: 12.5px; color: var(--muted-fg); padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px; line-height: 18px; } | |
| 1137 | .map-lane-word[data-autonomy="autonomous"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } | |
| 1138 | .map-lane-crew { display: inline-flex; align-items: center; gap: 4px; } | |
| 1139 | .map-lane-crew .avatar { box-shadow: 0 0 0 2px var(--card); } | |
| 1140 | .map-lane-head .map-counts { margin: 0 0 0 auto; } | |
| 1141 | .map-lane[data-idle="1"] > .map-lane-head .map-lane-name { color: var(--muted-fg); font-weight: 560; } | |
| 1142 | .map-lane > .map-canvas, .map-lane > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); } | |
| 1143 | @media (max-width: 600px) { | |
| 1144 | .map-lane-head { gap: 6px 10px; } | |
| 1145 | .map-lane-head .map-counts { margin-left: 0; flex-basis: 100%; } | |
| 1146 | } | |
| 1126 | /* ---- lanes: §5 list rows that fold ---- */ | |
| 1127 | #lanes { margin-top: 16px; } | |
| 1128 | #lanes .row-meta .map-counts { margin: 0; } | |
| 1129 | #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } | |
| 1130 | #lanes li[data-idle] .row-title { color: var(--muted-fg); } | |
| 1131 | #lanes details > .map-canvas, #lanes details > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |
| 1147 | 1132 | @media (prefers-reduced-motion: reduce) { |
| 1148 | .map-card, .map-title, .map-lane-caret { transition: none; } | |
| 1133 | .map-card, .map-title { transition: none; } | |
| 1149 | 1134 | } |
| 1150 | 1135 | @media (forced-colors: active) { |
| 1151 | .map-tool:focus-visible, .map-lane-head:focus-visible { outline: 2px solid Highlight; } | |
| 1136 | .map-tool:focus-visible { outline: 2px solid Highlight; } | |
| 1152 | 1137 | .map-node:focus-visible .map-card { stroke: Highlight; stroke-width: 3; } |
| 1153 | 1138 | } |
| 1154 | 1139 | `; |
| @@ -1294,12 +1279,12 @@ export const mapViewJs = /* js */ ` | ||
| 1294 | 1279 | |
| 1295 | 1280 | document.addEventListener('toggle', function (e) { |
| 1296 | 1281 | var d = e.target; |
| 1297 | if (!d || !d.classList || !d.classList.contains('map-lane') || !d.id) return; | |
| 1282 | if (!d || !d.id || d.id.indexOf('lane-') !== 0) return; | |
| 1298 | 1283 | folds[d.id] = d.open; |
| 1299 | 1284 | if (d.open) all(d).forEach(function (c) { setup(c, false); }); |
| 1300 | 1285 | }, true); |
| 1301 | 1286 | function restore(root) { |
| 1302 | Array.prototype.forEach.call(root.querySelectorAll('.map-lane'), function (d) { if (d.id in folds) d.open = folds[d.id]; }); | |
| 1287 | Array.prototype.forEach.call(root.querySelectorAll('details[id^="lane-"]'), function (d) { if (d.id in folds) d.open = folds[d.id]; }); | |
| 1303 | 1288 | if (root.classList && root.classList.contains('map-canvas')) setup(root, false); |
| 1304 | 1289 | all(root).forEach(function (c) { setup(c, false); }); |
| 1305 | 1290 | } |
| @@ -1654,51 +1639,47 @@ export function globalLanes( | ||
| 1654 | 1639 | ); |
| 1655 | 1640 | } |
| 1656 | 1641 | |
| 1657 | /** A lane: the repository's name, its autonomy mode, who is on it, and its map. */ | |
| 1642 | /** A lane: one folding list row — the caret, the repository's name and its | |
| 1643 | * autonomy mode, who is on it, its counts — over its map. */ | |
| 1658 | 1644 | export function laneHtml(lane: Lane, width: number): Html { |
| 1659 | 1645 | const { repo } = lane; |
| 1660 | const id = `lane-${repo.ownerName}-${repo.name}`; | |
| 1661 | return html`<details | |
| 1662 | class="map-lane" | |
| 1663 | id="${id}" | |
| 1664 | ${lane.idle ? html`data-idle="1"` : html`open`} | |
| 1665 | > | |
| 1666 | <summary class="map-lane-head"> | |
| 1667 | <span class="map-lane-caret">${icon("chevronRight")}</span> | |
| 1668 | <span class="map-lane-name" | |
| 1669 | ><a href="${repoBase(repo)}/map" | |
| 1670 | ><span class="muted">${repo.ownerName}/</span>${repo.name}</a | |
| 1671 | ></span | |
| 1646 | return row({ | |
| 1647 | lead: icon("chevronRight"), | |
| 1648 | title: html`<a href="${repoBase(repo)}/map" | |
| 1649 | ><span class="muted">${repo.ownerName}/</span>${repo.name}</a | |
| 1672 | 1650 | > |
| 1673 | <span class="map-lane-word" data-autonomy="${repo.autonomy}" | |
| 1674 | >${repo.autonomy}</span | |
| 1675 | > | |
| 1676 | ${lane.workers.length === 0 | |
| 1677 | ? null | |
| 1678 | : html`<span | |
| 1679 | class="map-lane-crew" | |
| 1680 | title="Working: ${lane.workers.join(", ")}" | |
| 1681 | >${lane.workers.slice(0, 5).map((w) => avatar(w))}${lane.workers | |
| 1682 | .length > 5 | |
| 1651 | ${badge(repo.autonomy, { variant: "outline" })}`, | |
| 1652 | meta: countsLine(lane.counts), | |
| 1653 | trail: | |
| 1654 | lane.workers.length === 0 | |
| 1655 | ? html`` | |
| 1656 | : html`<span title="Working: ${lane.workers.join(", ")}" | |
| 1657 | >${lane.workers.slice(0, 5).map((w) => html`${avatar(w)} `)}${lane | |
| 1658 | .workers.length > 5 | |
| 1683 | 1659 | ? html`<span class="muted small" |
| 1684 | 1660 | >+${lane.workers.length - 5}</span |
| 1685 | 1661 | >` |
| 1686 | 1662 | : null}</span |
| 1687 | >`} | |
| 1688 | ${countsLine(lane.counts)} | |
| 1689 | </summary> | |
| 1690 | ${drawMap( | |
| 1691 | repo, | |
| 1692 | lane.graph, | |
| 1693 | lane.placed, | |
| 1694 | emphasisFor(lane.ready, lane.critical, lane.marks), | |
| 1695 | { | |
| 1696 | id: `map-canvas-${repo.ownerName}-${repo.name}`, | |
| 1697 | minWidth: width, | |
| 1698 | cap: 0.6, | |
| 1699 | }, | |
| 1700 | )} | |
| 1701 | </details>`; | |
| 1663 | >`, | |
| 1664 | data: lane.idle | |
| 1665 | ? { autonomy: repo.autonomy, idle: "1" } | |
| 1666 | : { autonomy: repo.autonomy }, | |
| 1667 | fold: { | |
| 1668 | id: `lane-${repo.ownerName}-${repo.name}`, | |
| 1669 | open: !lane.idle, | |
| 1670 | body: drawMap( | |
| 1671 | repo, | |
| 1672 | lane.graph, | |
| 1673 | lane.placed, | |
| 1674 | emphasisFor(lane.ready, lane.critical, lane.marks), | |
| 1675 | { | |
| 1676 | id: `map-canvas-${repo.ownerName}-${repo.name}`, | |
| 1677 | minWidth: width, | |
| 1678 | cap: 0.6, | |
| 1679 | }, | |
| 1680 | ), | |
| 1681 | }, | |
| 1682 | }); | |
| 1702 | 1683 | } |
| 1703 | 1684 | |
| 1704 | 1685 | /** GET /map — every app the viewer can see, one lane each. */ |
| @@ -1718,13 +1699,13 @@ export function globalMapPage(ctx: Ctx): Response { | ||
| 1718 | 1699 | const body = html`${pageHead("Map", { |
| 1719 | 1700 | sub: "Every app you can see, laid out by what waits for what. The top edge of each lane is ready now; a ringed card is where the crew is.", |
| 1720 | 1701 | })} |
| 1721 | <div id="map-lanes" class="map-lanes" data-live="${LIVE_KINDS}"> | |
| 1702 | <div id="lanes" data-live="${LIVE_KINDS}"> | |
| 1722 | 1703 | ${lanes.length === 0 |
| 1723 | 1704 | ? html`<p class="muted"> |
| 1724 | 1705 | Nothing to map: no open issues anywhere you can see, and nobody |
| 1725 | 1706 | working. |
| 1726 | 1707 | </p>` |
| 1727 | : lanes.map((lane) => laneHtml(lane, width))} | |
| 1708 | : listCard(lanes.map((lane) => laneHtml(lane, width)))} | |
| 1728 | 1709 | </div>`; |
| 1729 | 1710 | const response = page({ |
| 1730 | 1711 | ctx, |
src/web/style/components.ts +4
| @@ -372,6 +372,10 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 372 | 372 | .row[data-state="closed"] .row-lead { color: var(--danger); } |
| 373 | 373 | .row[data-state="merged"] .row-lead { color: var(--merged); } |
| 374 | 374 | .row[data-state="draft"] .row-lead { color: var(--muted-fg); } |
| 375 | /* A row that folds is a <summary>: its lead is the caret, turned when open. */ | |
| 376 | summary.row { cursor: pointer; list-style: none; } | |
| 377 | summary.row:hover { background: var(--muted); } | |
| 378 | details[open] > summary.row > .row-lead { transform: rotate(90deg); } | |
| 375 | 379 | /* A count with its glyph: comments on a row, members on a team. */ |
| 376 | 380 | .count { |
| 377 | 381 | display: inline-flex; align-items: center; gap: 4px; |
src/web/ui.ts +11 −2
| @@ -834,10 +834,14 @@ export interface RowOptions { | ||
| 834 | 834 | data?: Readonly<Record<string, string>>; |
| 835 | 835 | /** The row is the one chosen (`aria-current="true"`). */ |
| 836 | 836 | current?: boolean; |
| 837 | /** The row folds: it is the `<summary>` of a `<details>` holding `body`, | |
| 838 | * open when `open` says so. Its `lead` is the caret. */ | |
| 839 | fold?: { id: string; open: boolean; body: Html }; | |
| 837 | 840 | } |
| 838 | 841 | |
| 839 | 842 | /** One list row. `<a>` when it navigates, `<div>` when it holds its own |
| 840 | * controls — the hover affordance follows automatically. */ | |
| 843 | * controls, `<summary>` when it folds — the hover affordance follows | |
| 844 | * automatically. */ | |
| 841 | 845 | export function row(o: RowOptions): Html { |
| 842 | 846 | const inner = html`${o.lead |
| 843 | 847 | ? html`<span class="row-lead">${o.lead}</span>` |
| @@ -860,7 +864,12 @@ export function row(o: RowOptions): Html { | ||
| 860 | 864 | .filter(([key]) => /^[a-z][a-z-]*$/.test(key)) |
| 861 | 865 | .map(([key, value]) => html` data-${raw(key)}="${value}"`); |
| 862 | 866 | return html`<li${data}${o.current ? raw(' aria-current="true"') : null}> |
| 863 | ${o.href !== undefined | |
| 867 | ${o.fold !== undefined | |
| 868 | ? html`<details id="${o.fold.id}" ${o.fold.open ? raw("open") : null}> | |
| 869 | <summary ${attrs}>${inner}</summary> | |
| 870 | ${o.fold.body} | |
| 871 | </details>` | |
| 872 | : o.href !== undefined | |
| 864 | 873 | ? html`<a href="${o.href}" ${attrs}>${inner}</a>` |
| 865 | 874 | : html`<div ${attrs}>${inner}</div>`} |
| 866 | 875 | </li>`; |
test/map-cache.test.ts +1 −1
| @@ -76,7 +76,7 @@ async function boot(shape: Shape): Promise<{ f: Fixture; servers: Servers }> { | ||
| 76 | 76 | const get = async (servers: Servers, path: string, cookie: string): Promise<string> => |
| 77 | 77 | (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 78 | 78 | |
| 79 | const lanesIn = (page: string): number => page.split('class="map-lane"').length - 1; | |
| 79 | const lanesIn = (page: string): number => page.split('<details id="lane-').length - 1; | |
| 80 | 80 | |
| 81 | 81 | describe("/map on a many-lane fixture, warm", () => { |
| 82 | 82 | let servers: Servers; |
test/map.test.ts +11 −4
| @@ -392,23 +392,30 @@ describe("the map, per app and across the forge", () => { | ||
| 392 | 392 | ).text(); |
| 393 | 393 | expect(page).toContain("<h1>Map</h1>"); |
| 394 | 394 | expect(page).toMatch( |
| 395 | /<div[^>]*id="map-lanes"[^>]*data-live="run\.queued,run\.started,run\.finished,issue\.\*,pull\.\*"/, | |
| 395 | /<div[^>]*id="lanes"[^>]*data-live="run\.queued,run\.started,run\.finished,issue\.\*,pull\.\*"/, | |
| 396 | 396 | ); |
| 397 | // A lane is a §5 list row that folds: its <li> and its <details>. | |
| 397 | 398 | const lane = (id: string) => |
| 398 | page.match(new RegExp(`<details[^>]*id="${id}"[^>]*>`))?.[0] ?? ""; | |
| 399 | page.match(new RegExp(`<li[^>]*>\\s*<details[^>]*id="${id}"[^>]*>`))?.[0] ?? | |
| 400 | ""; | |
| 399 | 401 | // engine has the crew on it: open, with who is working in its header. |
| 400 | 402 | expect(lane("lane-alice-engine")).toMatch(/\sopen\s*>/); |
| 401 | 403 | expect(lane("lane-alice-engine")).not.toContain("data-idle"); |
| 402 | 404 | expect(page).toContain('href="/alice/engine/map"'); |
| 403 | 405 | expect(page).toContain("Extract renderer"); |
| 404 | 406 | expect(page).toMatch( |
| 405 | /class="map-lane-crew"[^>]*>[\s\S]*?class="avatar"[^>]*>s</, | |
| 407 | /title="Working: s[^"]*"[^>]*>[\s\S]*?class="avatar"[^>]*>s</, | |
| 406 | 408 | ); |
| 407 | 409 | expect(page).toContain('data-autonomy="off"'); |
| 408 | 410 | // dormant has nothing ready and nobody working: it folds to its header. |
| 409 | 411 | expect(lane("lane-alice-dormant")).toContain('data-idle="1"'); |
| 410 | 412 | expect(lane("lane-alice-dormant")).not.toMatch(/\sopen\s*>/); |
| 411 | 413 | expect(page).toContain('<i data-state="parked"></i>parked <b>1</b>'); |
| 414 | // The lanes are §5's list and rows, not a page-local component (#3651). | |
| 415 | expect(page).toMatch(/id="lanes"[^>]*>\s*<div\s+class="card"[^>]*>\s*<ul class="list">/); | |
| 416 | expect(page).toMatch(/<details[^>]*id="lane-alice-engine"[^>]*>\s*<summary\s+class="row"/); | |
| 417 | for (const gone of ["map-lane", "map-lane-caret", "map-lane-crew", "map-lane-head", "map-lane-name", "map-lane-word", "map-lanes"]) | |
| 418 | expect(page).not.toContain(gone); | |
| 412 | 419 | // Every lane is drawn at the widest lane's width: one scale, one grid. |
| 413 | 420 | const widths = [ |
| 414 | 421 | ...page.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g), |
| @@ -442,7 +449,7 @@ describe("the map, per app and across the forge", () => { | ||
| 442 | 449 | await fetch(`${base}/map`, { headers: { cookie: await signIn("carol") } }) |
| 443 | 450 | ).text(); |
| 444 | 451 | expect(page).toContain("Nothing to map"); |
| 445 | expect(page).not.toContain('class="map-lane"'); | |
| 452 | expect(page).not.toContain('id="lane-'); | |
| 446 | 453 | expect(page).not.toContain("No open issues to map"); |
| 447 | 454 | |
| 448 | 455 | const anonymous = await fetch(`${base}/map`, { redirect: "manual" }); |
new above
offline