Skip to content

Design system port: replace Bootstrap 2 with the LinkedDataHub design system - #371

Open
namedgraph wants to merge 120 commits into
developfrom
rf-design-system
Open

Design system port: replace Bootstrap 2 with the LinkedDataHub design system#371
namedgraph wants to merge 120 commits into
developfrom
rf-design-system

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Ports the core end-user UI from Bootstrap 2.3.2 to the LinkedDataHub design system (vendored export), replacing markup — not skinning — while keeping every CSR interaction contract intact. Verified throughout against the running instance with Playwright (owner WebID client cert), screenshot-by-screenshot.

What's ported (design markup, div-based, no HTML5 semantic elements)

  • App shell: ldh-header (wordmark, address bar showing the document URI, Material-glyph icon buttons, initials avatar), ldh-sidebar with sb-* tree/classes/other sections; flyout mechanics unchanged
  • Action bar: zoned ldh-actionbarldh-btn Create with ldh-add-menu (design .it constructor entries), ghost Add, pills breadcrumb (bc-pill chips + chevron separators), schedule-glyph timestamp, single design Actions menu (Edit / Access / Save as · export links · danger Delete), labeled ldh-mode switcher with modes-pop
  • Blocks: ldh-block cards with ldh-block-head (badge/title/toolbar .tb actions incl. the drawer trigger); backlinks/related/parallax live in a per-block ldh-drawer; XHTML prose renders quiet (is-quiet); exactly one card layer (nested cards flatten)
  • Forms/modals: two-column property grid on the control-group contract, field skins with focus rings, modal card above the sticky chrome with a dim backdrop
  • Typography/tokens: colors_and_type.css, app.css, core.css, m3 skin; Geist / Geist Mono / Instrument Serif / Material Symbols vendored as local woff2 — no external requests

What's gone

  • Bootstrap 2 framework CSS deleted from the repo; bootstrap.js + jQuery unlinked (LDH glue rewritten vanilla in functions.js, IXSL contract intact); WYMEditor bundle removed
  • The ldh:logo sprite system: all 73 icon rules and the icons/ directory — Material Symbols everywhere
  • Bootstrap vocabulary purged by census: cosmetic tokens dead (well, span*, input-*, pull-* where inert, table-striped, …), anchored tokens renamed on both sides (span12row-main, row-fluidblock-row, form-horizontalldh-prop-form); LDH's own css/bootstrap.css renamed to ldh.css and pruned

Deliberately kept

  • CSR anchor tokens (block, control-group, dropdown-toggle/open, progress/bar, pager/previous/next, …) — styled by ldh-bridge.css, the documented transitional shim between CSR state classes and the design system
  • bs2: mode names and the xsl/bootstrap/2.3.2/ path — mechanical rename deferred
  • Web-Client's stylesheet link (its markup, its repo)

Known gaps (follow-ups)

  • Facets work in the block drawer, not yet the design's ldh-view-toolbar FacetDropdowns; container sort/view controls not yet view-mode-seg
  • Modal content (constructor forms) still legacy inside the design shell; search/access modals bridged, not mhead/mlist markup
  • Admin & auth screens out of scope this round
  • Upstream design-system landmine documented: the m3 skin's .ldh-header * color blast needs the inheritance fix + self-colored panels in the design project

🤖 Generated with Claude Code

namedgraph and others added 30 commits August 26, 2026 18:17
Blocks now carry the classes ui_kits/app/app.css targets, replacing the
Bootstrap grid ones:

  row-fluid block  ->  block ldh-block
  main span7       ->  main ldh-block-body
  well             ->  ldh-block-head

'block' and 'main' stay alongside the new names: 62 and 11 CSR handler
patterns anchor on those tokens, so dropping them would unhook drag-drop,
inline editing and the block toolbars.

The header gains the two flex children app.css expects - a titles group
(type list as badge, h2.ttl, description as span.sub instead of a <p>)
and an .actions group holding the timestamp and actions.

bs2:Left and bs2:Right emitted reserved grid columns for backlinks,
related resources and parallax navigation. The design system has no
columns; that content belongs to a block-scoped drawer, so they now
render inside div.ldh-drawer. They stay unconditional because CSR fills
them later.

Two CSR call sites had to follow the markup:

- view.xsl took the nav placeholders with a child step, which the drawer
  wrapper broke - faceted search would have lost its facets and parallax
  its controls, silently, since an empty for-each is not an error.
- chart.xsl and query.xsl lift the header out before replacing block
  content via ./div/div[@Class = 'well'], declared as="element()", so the
  rename would have been a runtime type error rather than a silent miss.

Still to port: bs2:Left/bs2:Right internals to the drawer's own
structure, the drawer's open/close behaviour, bs2:Actions buttons to .tb,
the YouTube header's inner markup, and dropping app.css itself in -
without which this renders unstyled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
colors_and_type.css (tokens), app.css (components), retro.css (m3 skin)
copied from the design system export; fonts.css @font-faces the vendored
woff2s - Geist, Geist Mono, Instrument Serif (latin subsets) and the
Material Symbols Rounded variable icon font - so no external font
requests. The root element takes data-retro="m3" data-theme="light",
which the skin keys on.

Linked after the Bootstrap stylesheets so the design system wins where
both target the same elements; the Bootstrap links come out at the end
of the port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bs2:NavBar becomes the ldh-header grid (wordmark | address | actions),
replacing the Bootstrap fixed-top collapse structure; NavBarLeft/Main/
Right and their span columns go. The brand takes the wordmark shape,
the address bar the ldh-address skin (same form, 'navbar-form' token and
input#uri kept for the CSR submit handler), and the right side becomes
ldh-icon-btn buttons with Material Symbols glyphs - apps grid, settings
gear, notification bell (admin) - plus an initials ldh-avatar for the
agent. btn-group/dropdown-toggle tokens stay: the CSR handler toggles
.open on them.

ldh:LeftSidebar takes the ldh-sidebar skin: sb-search field, sb-section/
sb-heading groups, sb-tree rows with Material carets. The flyout
mechanics are untouched - hidden by default, edge-hover shows, mouseout
hides - and document-tree/class-list/btn-expand-tree/btn-geo/btn-latest
anchors stay for the CSR handlers.

ldh-bridge.css is the deliberate shim between CSR state tokens and the
design system: dropdown .open menus, ul.nav strips, the tab bar, tree
rows and the expand/collapse carets, styled with the system's tokens.
Rules leave this file as components finish porting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generic bs2:Row got the drawer in the shell port, but two more block
templates - the ldh:XHTML content block and the edit-form row - still
applied bs2:Left/bs2:Right in-flow, so their backlinks headings rendered
inside the block body. The drawer markup is extracted into the
ldh:BlockLinksDrawer named template (dh title + close, db holding
bs2:Left/bs2:Right) and all three block shapes call it; bs2:Left/Right
defaults become drawer groups (left-nav/right-nav dgroup) and the
backlinks well becomes a dgroup with its nav-header kept for the CSR
load-on-click handler.

The header actions gain the tb-links trigger for @Rdf:about resources;
CSR handlers in block.xsl open the drawer (display flex) and close it
from its dh button. bs2:Actions loses the Bootstrap button faces:
btn-copy-uri and btn-edit become .tb toolbar icons (content_copy, edit)
with their handler tokens kept.

Verified in the browser: 14 drawers on the dev root document, all
hidden; clicking tb-links opens the block's drawer with hydrated
facet/backlinks content, its close button hides it again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bs2:ActionBar becomes the zoned ldh-actionbar grid (add | breadcrumb+
timestamp | actions), dropping its container/row/span wrappers. The
Create/Add cluster sits in ldh-add-wrap, breadcrumbs take the classic
ldh-bc skin on the same CSR-built ul, and doc-controls becomes the
quiet mono ldh-ab-ts stamp (History link unchanged).

The right-side document actions lose their sprite-logo faces: delete,
save-as, access and edit are .tb icon buttons with Material glyphs and
their identity tokens (btn-delete, btn-save-as, btn-acl btn-access-form,
btn-edit) plus disabled state written explicitly. Export and the layout
mode switcher stay dropdowns but with .tb trigger faces; mode glyphs
come from the new $ldh:mode-icons map shared by the switcher button and
the bs2:ModeListItem entries.

bs2:TypeList becomes the badge strip (ldh-typelist), facet headers move
from nav-header btn to the drawer's dh2 look, and the bridge styles the
read-mode dl/dt/dd as design-system property rows and ul.pager - whose
exact-class markup the CSR paging handlers match on - as prev/next
buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Modals: the CSR appends div.modal.fade.in and removes it on close, so
the bridge styles those tokens as the ldh-modal card - centered fixed
panel, radius/shadow, mhead-style header with close, scrolling body,
footer bar shared with .form-actions. No modal.xsl template changes.

Forms: control-group/control-label/controls stay (they are CSR anchors
for constraint violations and constructor rows) and become a two-column
property grid with explicit cell placement - RDF/POST hidden inputs and
<hr> separators are also direct children of .control-group and were
silently taking the first grid cell, pushing every label off its row.
Inputs, textareas and selects get the field skin with the focus ring;
type hints (.help-inline) read quiet and turn danger only inside .error
groups; submit buttons take the accent skin.

Verified in the browser: the document edit modal renders the two-column
statement layout with working focus states and footer actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
static/css/bootstrap.css and bootstrap-responsive.css are no longer
linked. ldh-bridge.css replaces the primitives LDH still emits - .btn
faces, carets, badges, alerts, progress bars, tables, typeahead menus -
at :where() zero specificity so design-system component classes always
win. The two remaining bootstrap.css links are app-specific rule files
(Web-Client's and LDH's own) that keep the historical name only.

Bootstrap-only vocabulary dies per the census of what the CSR actually
anchors on:

- purged outright (no anchors): span2-span10, offset*, well/well-small,
  sidebar-nav, nav-list, input-*, form-search, input-append, btn-small,
  btn-large, inline, table-striped/bordered, progress-striped,
  pull-* trimmed where flex replaced floats
- renamed on both sides, emission and contains-token anchors together:
  span12 -> row-main, row-fluid -> block-row,
  form-horizontal -> ldh-prop-form, alert alert-error -> ldhc-alert
  va-danger, container-fluid folded into content-body
- kept, still meaningful as CSR anchors: block, main, control-group/
  control-label/controls, progress/bar/active, dropdown/btn-group/open,
  modal family, navbar/navbar-form, breadcrumb, pager/previous/next,
  tab-pane/tab-content, nav-header, search-form/search-query
- btn-danger remove buttons become ldhc-btn in-negative, keeping their
  remove-action/link-remove handler tokens

The footer takes the design system's cols/ftitle shape, and the content
body gets its stacking rhythm and max-width from the bridge now that
Bootstrap's row margins are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dt followed by several dds dropped the extra dds into the term column
under grid auto-placement; dt/dd now take explicit columns. .btn-primary
picks up a link-colored text rule somewhere upstream, which vanishes on
the accent fill - one real-specificity color rule pins it to
--fg-on-accent. Content column takes a 1280px measure and the block
cards get their stacking margins back (Bootstrap's row margins are
gone).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, WYMEditor

static/css/bootstrap.css and bootstrap.min.css (the 2.3.2 framework
files) are deleted - nothing links them since the framework drop. The
script chain loses static/js/bootstrap.js and jquery.min.js: dropdowns,
modals and tabs have been IXSL-driven for years, and the only jQuery
left in LDH code was the RDF/POST subject-type/value glue.

js/jquery.js becomes js/functions.js with the IXSL contract intact -
fetchDispatchXML, onSubjectTypeChange, onSubjectValueChange and
ixslTemplateListener keep their names and behaviour (the two subject
handlers rewritten from jQuery to plain DOM). The Bootstrap-layout
pieces of its ready block die with the markup they served (btn-navbar
collapse, $().dropdown fallback); outside-click dropdown close and the
typeahead autocomplete-off live on as vanilla listeners.

The WYMEditor bundle (2.2MB, jQuery-based) goes too - replaced by the
RDFa editor in 5.7.0, nothing references it.

Verified in the browser: no jQuery-undefined errors, Export dropdown
opens via IXSL and closes on outside click, the edit modal renders. The
one remaining console 404 is the dev dataset's own broken test object
(?uri=https://example.org/aaa), not a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
com/atomgraph/linkeddatahub/css/bootstrap.css never was the framework -
it holds LDH's own application rules that accumulated under the
historical name. It becomes ldh.css, minus the ten rules whose selector
tokens no longer occur in any LDH or Web-Client markup (old breadcrumb
logos, action-bar span layout, btn-ban/btn-context, dead sidebar list
skins).

Web-Client's bootstrap.css stays linked: it styles Web-Client-emitted
markup and lives in that repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things made the page read as a wall of anonymous white cards rather
than the design:

- The SaxonJS bs2:Row wrapper carried the ldh-block card skin AND passed
  it to its next-match inner block, so every CSR-wrapped block rendered
  card-in-card - visible as sliver borders around alerts and empty-
  looking bars. The wrapper keeps only the 'block' anchor token now; the
  inner block is the card.
- ldh:XHTML content blocks are prose, and the design renders prose as
  quiet blocks (no surface, part of the page flow). Their class default
  becomes 'block ldh-block is-quiet', which app.css already styles.

The [Language: en] chip in the reported screenshot does not reproduce in
a fresh Chromium or Firefox - it is the stale-SEF browser cache
(/static/ is served immutable); a hard reload clears it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy .action-bar rule pins it at --action-bar-top with a 51px
fallback - the old Bootstrap navbar height - while the new ldh-header is
56px tall and was not sticky at all, so on scroll the header left the
viewport and content showed through a 51px gap above the stuck action
bar.

The header is now sticky at top 0 and --action-bar-top is defined as the
header height, so the two form one pinned chrome stack; #tab-bar's
legacy 51px offset follows the same variable. The action bar trades the
legacy gray fill for the surface color with a bottom hairline.

Verified scrolled 1200px down: header spans 0-56, action bar starts at
exactly 56, content scrolls beneath both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The breadcrumb items were still li > sprite-classed anchor + divider.
bs2:BreadCrumbListItem now emits the design's crumb anatomy - a.crumb
(is-current on the leaf) holding a Material glyph chosen by document
type (folder for Root/Container, description for Item, link otherwise)
plus the label, with span.sep between crumbs - inside the same ul both
render paths share; the CSR-created ul takes the ldh-breadcrumb classes.
The crumb builds its own <a> with the imports/default.xsl href recipe
rather than overriding the generic xhtml:Anchor mode, which would have
re-defaulted every anchor's href away from ldh:href().

The action-bar port had also silently reversed the document buttons:
the old markup floated each one right, so DOM order delete->edit read
on screen as edit->delete, and the flex container preserved DOM order
instead. The DOM now matches the screen order the floats produced:
edit, access, save-as, delete, then export and the mode switcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alert blocks

The container block's double bottom border had three contributors, found
by DOM forensics rather than guesswork:

- CSR hydration re-renders a row as a full ldh-block card inside the SSR
  wrapper card, stacking two borders 13px apart. One card layer only
  now: a nested .ldh-block flattens (no border/shadow/radius/surface).
- The token rename swept the XSLT but not ldh.css, orphaning its
  row-fluid/span12/form-horizontal/input-append selectors - drag-handle
  hiding, block clipping, drag-over and diff borders, the progress-bar
  sizing that kept the loading skeleton invisible after errors, and the
  search-field flex. All follow the rename (block/block-row/row-main/
  ldh-prop-form); the dead action-bar span rule is deleted.
- A block whose content collapsed to a single error alert kept its card
  chrome, framing the alert with white lips. Such blocks go quiet via
  :has() - the alert brings its own surface - and alerts lose their
  stray margins inside blocks. The m3 skin's resting M3-elevation
  shadow, which reads as a hard line under every card, rests flat per
  the design's card preview (border instead; hover unchanged), and the
  bridge table rule no longer draws a separator under the last row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bar's controls were still Bootstrap skeletons (btn-group +
ul.dropdown-menu) under bridge CSS. They now emit the design system's
own structures, as divs per house style:

- Create: ldh-add-wrap > ldh-btn (add glyph + label + caret) +
  ldh-add-menu with .it entries; bs2:ConstructorListItem renders the
  design item shape (ico/body/lbl, folder for Container, description
  for Item) while keeping the add-constructor/data-for-class/
  data-create-graph contract. Both end-user and admin variants.
- Add: ldh-of-wrap > ghost ldh-btn + ldh-of-menu, generate-containers /
  import-ontology tokens kept.
- Document actions collapse into the design's single Actions menu
  (bolt + label + caret): Edit / Access / Save as, a divider, the three
  RDF export links (target=_blank keeps them outside CSR interception),
  a divider, and a danger-styled Delete - handler tokens and disabled
  states preserved. bs2:MediaTypeList dies with its dead export-rdf
  interception exemption; its links live in the menu now.
- Mode switcher: ldh-mode > label-row trigger (glyph + active-mode label
  + caret) + modes-pop with .mi entries as anchors, mode-class and
  active tokens kept alongside is-active.
- Breadcrumbs switch to the pills variant: bc-pill chips with chevron
  separators in a div container on both render paths.
- The timestamp gains the schedule glyph; the address bar shows the
  document URI on SSR and always mirrors it on navigation (previously
  blanked for local documents - the design's Header shows the URL).

All design menus open through the existing CSR btn-group 'open' toggle;
the bridge maps that state onto ldh-add-menu/ldh-of-menu/modes-pop
visibility and exempts the address field from the form-field skin.

Verified in the browser before the stack was stopped externally: Create
pill, Actions menu with dividers and danger Delete, pill breadcrumb,
labeled Content switcher all render and open; the address-bar change
compiles but its browser check is pending the stack's return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The XHTML/Object create-block buttons were the last old-school controls:
Bootstrap primary pills with sprite faces. They are ghost ldh-btns now,
with Material glyphs (notes / data_object) and their create-action /
add-constructor / data-for-class contract intact.

The sprite system itself goes: all 73 background-image rules pointing at
the icons/ directory are deleted from ldh.css and the directory itself
is removed - every ported control renders Material Symbols instead. The
ldh:logo templates stay purely as class-token carriers for the CSR
handlers that anchor on them; with the sprite CSS gone they paint
nothing. The one non-CSS consumer, the signup-complete <img>, becomes a
check_circle glyph.

Verified in the browser: the create-block buttons render as quiet design
pills, and the address bar now shows the document URI (the CSR change
from the previous commit, unverifiable then while the stack was down).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge's Bootstrap-replacement .caret was a 0x0 border triangle,
which collided with the ported markup's <span class="msi caret">
expand_more</span>: the triangle painted inside the button while the
glyph text overflowed the zero-size box as a stray chevron below it.

.caret is now a Material glyph in both generations - ported spans render
their own expand_more, and legacy empty <span class="caret"/> spans
(login dropdown, sort toggle, the drawer's backlinks heading) get the
same glyph via ::before, with caret-reversed flipping to expand_less.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…les only

Of the empty <span class="caret"/> leftovers, five were static dropdown
carets - they now ship the expand_more glyph directly, like the rest of
the ported markup. The two caret-reversed collapse indicators stay empty
by design: the CSR handlers flip the caret-reversed class on them, and
only ::before content can follow a class change on a ligature-font
glyph. The bridge comment records that distinction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sticky action bar (z-index 999 from the legacy rules) painted over
modal dialogs, hiding their header - the bridge had given modals z-index
110. Modals sit at 1050 now, above the whole chrome stack, with a
non-interactive dim behind them via body:has(); typeahead suggestion
menus move above the modal plane so in-form typeaheads keep working.

Design menus (Create/Actions/modes) also stayed open after picking an
item. The outside-click closer now also closes on menu-item picks,
deferred a tick so it settles after the IXSL btn-group toggle that
handles the same click - otherwise the two could race and re-open the
menu.

Verified in the browser: Create > Container opens the modal above the
chrome with the page dimmed behind it, and the Create menu is closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The m3 skin paints every .ldh-header descendant with the inverse near-
white text color for the dark bar - correct for the bar itself, but the
light dropdown panels inside it (Applications, Settings, agent) rendered
white-on-white and read as empty. Panel content takes the regular ink
back at higher specificity, section headers keep the hint color, and the
avatar's initials stay dark in its hover/open state where the fill goes
light.

Settings had no pull-right token, so its menu left-aligned and clipped
off the viewport; header menus now hug the right edge regardless.

Menu picks also close these header menus via the same deferred closer as
the action-bar menus.

Verified in the browser: all three menus open readable and right-
aligned - System apps list, Application/Administration/Namespace
ontology, and the agent entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Census: zero CSR anchors on pull-*, and floats are inert inside flex
containers and on absolutely positioned elements - so every pull token
whose parent already became flex chrome (header action strips, block
head actions, alerts, dropdown toggles) or that rode an absolute menu
was doing nothing. Those 14 are gone; the Actions wrapper layout keys on
its position in .actions instead of the token.

What stays, deliberately: pull-right on ul.dropdown-menu as the
alignment signal for the not-yet-ported menus, and the still-active
floats in interior form markup (btn-remove-property/btn-add rows,
typeahead metadata, the hover-overlay edit buttons) - those go when
their components get ported, and the bridge documents exactly that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy modal markup puts button.close FIRST and titles the dialog
with a <legend>; the bridge's flex header laid children out in DOM order
- close at the left, the title squeezed into a wrapping column, the
description eating the rest. The header is a two-column grid now: the
title column (legend/h2/h3 styled as the design's mhead title, any <p>
as a muted subtitle under it) and the close button pinned top-right,
regardless of source order.

Verified on the Request access modal: title left, description under it,
close at the right - matching the design's mhead anatomy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both bs2:Chart forms (the RDF and SPARQL-results variants, which the CSR
re-render also compiles in) swap their fieldset block-row for the design
system's chart-controls three-column grid with .field cells - mono
uppercase overline labels, full-width selects, the series multi-select
as a listbox. Select classes/ids (chart-type / chart-category /
chart-series, name=ou|ol) are untouched, so the onchange handlers and
the RDF/POST encoding are unaffected.

Fieldsets and legends lose their Bootstrap-era box look globally:
borderless sections with overline legends, which also fixes the
old-school group boxes in the access modal. Multi-selects inside the
controls grid keep their natural height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-DOM audit (Playwright crawl of every menu, modal, form and mode
against the design-system contracts) surfaced the components that lost
their faces when the sprite CSS died, plus one real regression risk:

- The constructor modal had an empty <legend> - it is titled with the
  class label now (ac:object-label in xsl:try; the bare call throws and
  killed the whole modal render, which the first fix attempt proved the
  hard way. The catch falls back to the URI local name.)
- Remove-statement and remove-resource buttons were empty invisible
  boxes in every editing form: .tb buttons with the close glyph now, in
  all three emitters (constructor.xsl - with the xsl:attribute reordered
  before the glyph span, imports/default.xsl, resource.xsl), handler
  tokens kept
- The search modal's submit button was invisible: search glyph
- Add-statement button swaps the HTML entity cross for the add glyph
- Graph HUD buttons gain fit_screen / fullscreen glyphs per OtherModes
- Dead tokens purged: wymupdate, dl-horizontal, the sprite-era class
  injection on block-title anchors

Verified in the browser: Create > Container opens titled with glyphed
remove buttons (4/4); the per-block edit button opens the inline editing
form with glyphed controls and footer actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A faceless-button DOM sweep (every button with no text, glyph or image,
across edit mode, the create modal and the inline block edit form)
caught the emitters whose only face was a sprite class injected via the
ldh:logo mode: the subject-edit toggle in form legends (edit glyph), the
add-value button on property controls (add glyph) and the rdf:type
control group's remove button in imports/rdf.xsl, which now matches the
canonical tb btn-remove-property + close-glyph shape. Dead sprite
background-position rules and the border-triangle caret-reversed rule
(the double-arrow bug's last holdout) leave ldh.css. Re-swept after
deploying: no faceless buttons remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…glish go with it

The facets answer to 'where did the filters go': out of the links
drawer, into a ldh-view-toolbar at the top of every view block, per the
ViewBlocks contract. Left zone: filter_alt lead + one dropdown pill per
facetable property - the pill is the old nav-header (button-shaped, same
hidden subject/predicate/object inputs), so the lazy value-load onclick
and the checkbox filter onchange run unchanged; the hydrated ul.nav now
doubles as the facet-pop popover card. Right zone: the inline-create
button, Total results, the order-by select + direction button, and the
mode dropdown (with a visible active-mode glyph from ldh:mode-icons).
The drawer group the facets vacated hides when empty; parallax stays in
the drawer. Verified in the browser: pill opens, values load with
counts, checking a value filters the results.

The last pull-left/pull-right tokens are purged along the way - header
menus right-align via a header-scoped rule, the form legend is
restructured (label first, subject controls in a right-hugging .actions
cluster, remove button last), remove/add groups trail their form rows
via flex order, the language badge follows its value, and the typeahead
metadata span is .meta. The bridge float shims are deleted.

Branch-introduced English strings move to translations.rdf (add-stmt,
remove-resource, remove-link, get-started, learn-more, footer titles
and links, export format names); the facet lead reuses filter-title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three serialization links (RDF/XML, Turtle, JSON-LD) move out of the
Actions menu into a new bs2:ExportList mode that bs2:ActionBarRight
renders after the mode switcher: an icon-only ghost button (download
glyph, titled from the existing export translation entry) opening the
same target=_blank links. Same signed-in guard and HTTP-error
suppression as before, same ldh-of-wrap anatomy, so the open-toggle and
close-on-pick wiring covers it unchanged. The Actions menu keeps
edit / access / save-as / delete, minus the now-stray second divider.

Verified in the browser: the button renders right of Content, its menu
lists the three formats with the correct accept hrefs, and the Actions
menu no longer carries them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The edge-hover flyout stopped appearing: the port stamped the sidebar
with the design's ldh-sidebar class, whose resting state is
translateX(-100%) waiting for an is-open toggle - but the CSR handlers
carry show/hide as inline display (mouse at x=0 sets block, mouseout
sets none). The handler fired, display flipped, and the panel stayed
288px off-screen. The bridge now neutralizes the slide transform so
display alone controls visibility, and starts the panel below the
sticky header instead of the design's full-height top:0.

Verified in the browser: mouse at the left edge slides out the panel
(search, document tree, class pills, other views) at x:0 under the
header; mousing out hides it again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ith it

Every form footer (modal and inline) now follows the design's footer
anatomy: secondaries (Cancel/Reset/Close) are ghost ldh-btns, the
primary (Save/Compare/Allow/Annotate) is a filled ldh-btn with a glyph,
and it sits last so the ghost buttons lead into it. Handler tokens
(btn-save, btn-cancel, btn-reset, btn-close, btn-save-chart, spo-action,
cancel-action) ride along unchanged, as do the two $button-class param
defaults that feed the ldh:logo class injection.

The Bootstrap-era form-actions class itself is gone: the design system
has no such band (its ldh-form-actions-* names mean the per-form actions
dropdown), so the footers take the design's ldh-block-foot token - all
emitters, the one CSR matcher that anchored on it, and the bridge rules
renamed together. modal-footer stays as the sticky-scroll token, its
hardcoded white swapped for the surface token. Also fixed en route: a
stray quote in ldh:Form's save button class, and the access form's
hardcoded Allow label now comes from a new translations entry.

Verified in the browser: the create modal footer renders ghost Reset +
filled glyphed Save on the ldh-block-foot band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…column

The rdf:RDF form shell gains a show-close-button param (default false, so
the SSR EditMode page keeps its plain footer) emitting a ghost Close
button ahead of Reset; the generic modal.xsl close handler already
matches any button.btn-close inside a modal, so no new wiring. All six
modal flows through the shell opt in: document create, document edit,
add-instance, app settings, and both constraint-violation re-render
paths. The three modal footers that already had a Close (import
ontology, generate containers, request access) join the new convention:
ghost Close and Reset leading into the ldh-btn primary. Also swept up:
form-actions-class param values that still said 'form-actions' (the
class-literal rename missed them), and modal.xsl's button-class defaults
move off btn btn-primary.

Modals now span the content column - width matches .content-body's inner
width (max 1280px minus side padding) instead of the 880px cap.
Verified in the browser: create modal footer reads Close / Reset / Save,
inline form reads Cancel / Reset / Save, and the modal box is
pixel-identical in x and width to the content blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
namedgraph and others added 30 commits August 29, 2026 16:59
…eTime branch that had been written out at every site. dct:created and dct:modified carry either granularity and xs:date and xs:dateTime are not mutually comparable, so each site normalised to xs:dateTime by appending 'T00:00:00' to the lexical form - which puts the timezone before the time part on a zoned date and yields '2024-01-05ZT00:00:00', not a valid xs:dateTime. That is FORG0001 rather than a misordering: a zoned xsd:date in either property took out the whole transform, and the guards let it through because such a value is castable as xs:date. Casting through xs:date() instead keeps the timezone and cannot fail once the value is known to cast.

Three sites collapse onto the function: the bs2:Timestamp sort in resource.xsl, the ldh:ListRowTimestamp sort in view.xsl together with the format-date call below it that repeated the same branch to get its xs:date back, and the action bar's schedule-icon guard in document.xsl, whose filter now reads as the exists() it always meant. The function lives beside ldh:datatype-family() in imports/default.xsl and stays free of ixsl, so the SSR chain that reaches resource.xsl and document.xsl through layout.xsl still compiles. Checked across plain and zoned dates, dateTimes with and without a timezone, and non-dates: the first four normalise as expected and feed format-date, the rest return ().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…ypes. Both enumerated only xsd:integer, decimal, double and float as chartable numbers and xsd:dateTime alone as a datetime, so a column of xsd:long - 18 of them in the Northwind data - typed as a string and its values serialised as quoted strings, charting as categories rather than a numeric axis. The same held for xsd:int, short, byte, the unsigned family, the bounded integer types and xsd:dateTimeStamp, all of which restrict a type the converters already handle and are exactly as chartable as it.

The widened list lands in both the column-type classification and the cell-value pattern in each file: they have to move together, since a column that types as 'number' while its cells still emit strings is worse than one that types as a string throughout. Each converter keeps its own copy - they carry no imports and no ldh: dependency, and stay compilable on their own. Verified by running both over the affected types: xsd:long and xsd:dateTimeStamp now yield {"type":"number"} with 42 rather than "42" and {"type":"datetime"} with Date(2024, 2, 11, 14, 47, 0, 0), a mixed long/unsignedShort column still types as a number since every member is now in the list, and output over the previously supported types is byte-identical to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…e the source happens to carry. The object templates - foaf, schema and dbpedia-owl in Web-Client's imports server-side, the consolidated copy in client.xsl for CSR - emit a bare <a><img></a> with nothing bounding it, so the view's table mode put a 200px headshot in one row and a photo taller than the viewport in the next, and the same markup reaches property-list dd values and the map info window. Grid mode was the only one already normalised, fitting each card's image to a 4/3 frame with object-fit: cover, and list mode renders a type icon instead.

A --thumb-sm/md/lg scale in colors_and_type.css carries the caps and ldh-bridge.css spends them on the three unbounded surfaces: table.table td at 48px, the property-list dd at 160px, the block head at 240px, each picking up the card language's radius. Only max-height and max-width are set, so every source stays proportional and images already smaller than the cap are left alone - a fixed box would have to crop or letterbox, and a depiction is as often a logo, a map or a scan as it is a headshot. The map info window needs no rule of its own, since its overlay hosts a .ldh-block property list and both rules outrank the .ol-overlay-container img catch-all in ldh.css on specificity and on load order. The rdfa-editor builds tables without the .table class, so author-written content keeps its images at natural size.

The block head drops width: 100% for max-width: 100% on the way. Its grid track is a fixed minmax(140px, 220px) whatever the image does, so the title alignment that rule exists to protect comes from the track rather than from the image filling it, and stretching bought nothing but an upscaled logo. The height had to be bound there regardless or a portrait source grew the header past the property list it heads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…d-A stops falling back mid-word. All three families were vendored as the Google Fonts latin subset alone, and each @font-face repeated that subset's unicode-range - U+0000-00FF plus a handful of named codepoints, nothing between U+0100 and U+017F. The files match the declaration: 225 codepoints in geist.woff2, none above U+2212. Every Lithuanian diacritic therefore missed Geist and was substituted per glyph from the next family in --font-sans, which is system-ui, so a name like Balčytis or Širinskienė rendered as Geist spliced with SF Pro at different widths and weights inside one word. The quotes in „Vardan Lietuvos" were unaffected, being in the latin subset's U+2000-206F.

Each family now declares the latin and latin-ext faces as a pair the way Google Fonts serves them - same family name, disjoint unicode-range, separate file - which keeps the extended subset off the wire for pages that never reach past U+00FF. The two Geist files carry the same wght 100-900 axis as their latin siblings and Instrument Serif stays static at 400, so the weight declarations remain honest. Coverage is verified across the affected languages, Lithuanian along with Polish, Czech, Turkish, Hungarian, Latvian and Estonian: every character resolves within the family for all four faces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…e. bs2:Actions defaulted $show-edit-button to true(), so the button rendered on every card the default template mode produced - and the form it opens submits a PATCH, which AuthorizationFilter maps to ACL.Write along with PUT and DELETE. A reader therefore got an edit affordance on each block of a document they cannot write, which fills in from the server, lets them change values and fails on save. The block hover buttons in bs2:Row and the Actions-menu item in bs2:NavBarActions already tested that mode; only the header corner did not. The default becomes the same acl:mode() test, which resolves from the response Link headers server-side and from the pane's stamped acl-modes flags in the browser, so SSR and CSR gate alike. No call site passes the tunnel param true() - the view, map and object blocks all pass false() - so nothing else moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…df-document-response aligned the URL with the mode the document resolves to - ac:mode(), which returns the ?mode param if given and otherwise derives ldh:ContentMode or ac:ReadMode - and did so with history.pushState. That mode is the default the bare URL already denotes, so the alignment was writing a second entry for a state the first entry already stood for: clicking through to /constituencies/61/#this landed there and immediately moved to /constituencies/61/?mode=...ReadMode#this, and back then popped to the mode-less twin, where popstate either found a null state (cold load) or took the ldh:TabSwitch branch for the same document. Either way nothing visibly changed and a second press was needed to leave. The same push also appended after a popstate - both popstate branches call ldh:DocumentNavigate with push-state=false precisely because the entry exists - so back-navigation into an uncached document dropped the forward stack.

The alignment now replaces. ldh:PushState takes $replace and picks history.replaceState over history.pushState for the same arguments, and $container defaults to the body element both alignment sites were passing literally. The push stays where it belongs: ldh:DocumentNavigate and ldh:TabSwitch, each gated by $push-state, are the only callers that add an entry.

The two alignment sites - the 200/RDF path and the error path - were already setting document.title and pushing the same href from the same four values, so they collapse into ldh:SetDocumentState, which sets the title and replaces. It has no push branch, so the fetch path can no longer add an entry by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…t. Every block error path emitted div.alert.alert-block with a <strong> headline and the upstream message in a bare <pre>, and alert-block is not alert-error, so all thirteen landed on the :where(.alert) fallback in ldh-bridge.css - painting a hard failure on warning-amber ground, with the endpoint URL as the loudest text and Varnish's "Backend fetch failed" as the entire body copy. They now build from ldh:error-alert, the InlineAlert va-negative that core.css already ships: a headline naming what failed, a sentence under it that ldh:http-error-key derives from the response status instead of restating the upstream text, the URI in the body's link slot, and the raw status and message demoted into a collapsed ldh:error-detail disclosure - never the first thing read, never withheld from whoever needs it.

The card stops collapsing. The bridge rule that quieted a block whose body had become a single alert is gone: the design system decided the chrome and header stay in every state, because the header carries the affordances a broken block needs most (copy IRI, edit, delete) and holding the footprint keeps the document from reflowing. app.css rings the card instead, keyed off :has(.ldh-block-error) rather than the design's is-failed class, so the ring is a function of what the handler rendered and cannot survive a later load that succeeds - nothing has to clear it. The hosts that are not a block body call ldh:error-alert directly and so cannot ring the card they sit in: the facet popover, the parallax rows, the modal's endpoint-classes region and memento's modal body; the view toolbar's result count takes a compact ldhc-tag, an alert outweighing the row it reports on.

The for-each over the container and its replace-content result-document, written out at each of the ten block sites, collapse into ldh:render-block-error, retiring the unnamespaced render-container-error. Its twin ldh:render-form-error covers the modal form surface and folds in two more renderers that were still on the legacy class: ldh:add-data-form-error, which had its own div.alert and a status-by-code lookup against http-statusCodes.rdf, and ldh:generate-containers-join, whose hand-rolled <ul> of failed classes becomes one "HTTP <status> <class>" line each in the same disclosure. Copy moves to translations.rdf with es-ES alongside, closing the four TO-DO: localize markers on the add-data path and memento's three hardcoded strings, and retiring error-during-query with its last caller. ldh:http-error-key takes xs:double because SaxonJS surfaces the status as a JS number: integers promote into that, doubles do not promote back, and an xs:integer signature would have thrown XPTY0004 at runtime on every call while still compiling to SEF.

Retry and the header state pill are not here. Both need a load-start hook to clear on re-render - ldh:load-block provides one for initial loads, but ldh:refresh-view and the sort, filter and parallax paths bypass it - so a pill set on failure would go stale the first time a facet change succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…f the same ixsl:set-style on the body, and lowering it was 48 more scattered through the response callbacks, hand-paired: each callback reset the cursor once per terminal branch it happened to think of. The branches it did not think of were the error ones - ldh:left-sidebar-resource-response resets inside its 200 branch and only logs a message in the other, so a response that resolved with a 4xx or 5xx left the cursor spinning until the next navigation. ldh:promise-failure covers a rejected promise, not a resolved one carrying a failed status, so nothing caught it.

Raising now goes through ldh:busy-cursor(), the counterpart of the ldh:reset-cursor() that already existed for ixsl:finally, and lowering is that finally on the chain the work runs in - 47 chains carry it now against the 8 that did. A chain settles once whatever the outcome, so the reset happens exactly once per interaction and cannot miss a branch. constructor.xsl keeps its own set-style: it targets an element rather than the body, so it is not the same concern wearing the same name.

ldh:ClassListLoad needed its shape changed rather than a clause added. It is two hops - list the class types, then DESCRIBE them - and ldh:class-list-response fired the second as a bare ixsl:promise element, which the outer chain does not adopt: a finally on the outer would have dropped the cursor while the describe was still in flight, which is why the resets there sat in the branches that do not start a second hop and in ldh:class-list-describe-response for the branch that does. It now returns the describe chain instead of firing it, with ixsl:resolve($context) on the branches that have nothing to await, so the caller's ixsl:then adopts it and one finally covers both hops (idiom: ldh:fetch-and-load-edited-resource). The other eleven nested promises were checked: ldh:facet-value-response fans out per-type describes fire-and-forget and already reset before they finished, so a finally on its caller is timing-identical, and the rest are either off the cursor paths or already carried their own.

Only eleven of the old resets are gone - the ones in callbacks where every chain naming them now carries a finally, matched mechanically rather than by eye. Thirty-five stay: the ixsl:schedule-action paths (block delete, map feature click, the js:fetchDispatchXML upload) have no chain to hang a finally on and want converting to ixsl:promise first; ldh:RenderViewMode raises the cursor that ldh:LoadGeoResources lowers two files away; and ldh:promise-failure keeps its own reset deliberately, as 61 chains name it as on-failure and not all of them have a finally yet. Resetting an already-reset cursor is a no-op, so leaving them is harmless - it just means the sweep is partial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…se chains, and the add-data form error takes its explanation as an argument rather than a smuggled map key.

The schedule-action form has no chain to hang an ixsl:finally on, which is why block delete, the map feature click and the document delete were the paths still resetting the busy cursor by hand. Each one's onX named template - which read the response off the context item, the schedule-action calling convention - becomes an ordinary context-map function: ldh:block-delete-response, ldh:feature-description-response, ldh:delete-response. None of the three chains through ldh:handle-response, because all three report a failed status themselves rather than raising it, so the response has to reach the callback rather than divert to on-failure.

Two of them take a finally and drop their hand-written resets. The document delete deliberately does not: on 204 it hands over to ldh:DocumentNavigate, which raises the cursor for its own load, and a finally on the delete chain would settle first and clear it - so that one keeps the reset in its failure branch, which is the only branch that ends there. ldh:LoadGeoResources and ldh:refresh-view also gain the finally they were missing (the cursor for the map view is raised by ldh:RenderViewMode two files away, and lowering it belongs on the chain that does the loading), which retires ldh:geo-results-response's pair of resets. Hand-written resets are down from 48 to 28.

ldh:add-data-form-error's 'explanation-key' was an optional context-map entry that only four of its nine callers set, two of them via map:put purely to pass one string, and discoverable only by reading the function. It becomes a second parameter, with () meaning derive the sentence from the response status; map:contains goes with it. ldh:import-ontology-error and ldh:import-ontology-error-cleaned thread it through, the latter keeping its partial-application shape as ($context, $explanation-key, ?).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
… with a query instead, which also makes them bidirectional. The old grouping over the page RDF could only see the properties of the resources on screen, and offered predicates whose objects are never subjects themselves - on the Northwind employees view that meant a dead 'type' chip pivoting onto schema:Person, a vocabulary term with no triples in the end-user dataset. ldh:link-predicates builds the replacement as JSON XML in the idiom of its neighbours: the view's own SELECT becomes a subquery (in its own group, since a SubSelect must be the sole content of its braces) with LIMIT/OFFSET/ORDER stripped and its focus variable re-projected DISTINCT, so discovery covers the whole result set rather than the current page; around it a union asks for the predicates leaving the results and the predicates pointing at them, tagged apart by a bound boolean. Objects that are literals or that are never subjects are filtered out of the outgoing branch - a pivot onto them renders an empty view - while incoming subjects need no such check, each being by construction the subject of the triple that matched. Discovery variables carry an ac:uuid() suffix because a subquery exports its projected variables and a view projecting ?predicate would otherwise silently join with ours, and the wrapper deliberately declares no prefixes: with any, the generator abbreviates IRIs and then emits the subquery's own prologue inside the group.

The chips become directional end to end. ldh:add-parallax-step takes a tunneled $inverse that swaps subject and object in both the plain BGP and its GRAPH twin, so a backward pivot re-projects onto the subjects pointing at the current results; the direction rides the chip as @data-dir into the pivot handler, is recorded with each step, and is replayed by ldh:ReplayParallaxSteps, which now walks step maps rather than bare predicate URIs. Applied steps read 'via' or 'linked by' with a matching arrow, and the row's own glyph goes neutral now that the chips carry their own.

Two requests per render replace the previous one-DESCRIBE-per-predicate fan-out: the discovery query, then a single batched CONSTRUCT for labels. Labels come from the /ns ontology endpoint via the existing $object-metadata-ns-query rather than $property-metadata-query's bare DESCRIBE, since all that is consumed is ac:label and vocabulary terms are described in the ontology, not the data - the same fact the dead-end filter exploits. Knowing the full predicate list up front lets the strip render once, retiring the read-the-DOM-back-and-re-sort merge that only existed because chips used to arrive one response at a time, and the label fallback cascade moves into ldh:predicate-label. The result-set-invariant query string is cached as parallax-key so paging, sorting and mode switches re-render without re-discovering, and the guard that skipped discovery whenever no resource had an IRI-valued property is gone - result sets carrying only literals are exactly where incoming links may be the only pivots available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…as written against .ldh-block across the glasspro and m3 skins, but .is-quiet dissolves the card it elevates - no border, no background, no shadow - so a prose block translated a pixel with nothing around it to move, which reads as a wiggle rather than as elevation. Scoping each rule to :not(.is-quiet) leaves the lift where the card still exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…ent renders every rdf:type/@Rdf:resource as span.btn.btn-type, and the bridge styled it as a pill only through ul.ldh-typelist > li > span - so the resource description header got the design system's Tag em-quiet co-primary sz-sm (20px, pill radius, blue-50 on blue-700) while the identical span in a view's Type column fell through to :where(.btn), the 30px bordered fallback that stands in for un-migrated Bootstrap buttons. The declarations move onto .btn-type unchanged, ul.ldh-typelist keeps its layout, and a type now reads the same wherever it is rendered. Its dropped hover rule had never matched anything: the anchor is a grandchild of the li, so ul.ldh-typelist > li > a:hover selected nothing.

Table cells align middle. With the Type column's chip 30px tall against ~20px line boxes, vertical-align: top parked every date and decimal above the chip's label - a sawtooth across each row. core.css ships no table, so the convention comes from how the system aligns elsewhere: centred wherever a single-line label sits beside a control (.ldhc-btn, .ldhc-ubtn, .ldhc-tag, .ldhc-badge, .ldhc-switch-seg), flex-start reserved for the four cases of a wrapping text block beside a fixed-height one (.ldhc-help, .ldhc-choice, .ldhc-field-foot, .ldhc-toast). A cell holding one date is the first case, as the neighbouring td img rule already assumed. The pill's own 20px height settles most of it regardless - the chip stops setting the row height, and rows tighten by about a quarter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
… block state instead of failing on a datatype. ldh:RenderChart handed every DataTable to Google Charts, which validates column types whether or not there are rows to draw - and a variable bound in no result types as a string, because SPARQLXMLResults2DataTable decides a column by asking whether all of its bindings have a given shape, and at zero bindings every one of those count comparisons is 0 = 0, so the first branch answers. A SELECT matching nothing therefore surfaced as "Data column(s) for axis #0 cannot be of type string" - a type error standing in for an absence of data, and one that no choice of category or series could clear, since the type was never read off a value in the first place. The row count is now taken from the DataTable before drawing, which keeps the guard shape-agnostic across SRX and RDF/XML results, and ldh:RenderChart is where it belongs: all eight call sites funnel through it and it is ac:draw-chart's only caller.

The converter's typing is left alone. What it reports for a column with no values is arbitrary whichever branch answers, and picking the one type Google Charts accepts on a value axis would be describing the data wrongly to keep a validator quiet - 'number' for a column of supplier names. Emptiness is a fact about the result set, so the chart block is where it belongs.

What an empty block renders was already specified: Components -> Block states carries a failed, an empty and a loading body, of which only the failed half had been ported. .ldh-block-blank and its three children come over with their declarations unchanged, so the chart's empty body is the same component a view's will be - the spec's example is a view, but the state is a block state and its action row is a separate optional child, which a chart with no filters to clear simply omits. No ring: an empty result is an answer rather than a fault, and nothing needs finding in a long document. The canvas yields its fixed 400px and its padding when it holds the empty state, since .ldh-block-blank brings its own inset and would otherwise sit stranded above 400px of nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…system stamps a block-state marker only in the state it names, which means whatever renders the block has to remember what it stamped and clear it when the state changes. The bridge inverts that: every resource-description header carries span.ldh-block-state.is-empty and app.css reveals it only under .ldh-block:has(.ldh-block-blank), so the marker is derived from the body the same way the failure ring above it is, and a block that goes from empty to having rows drops its marker on re-render with nothing to unset. Hence display: none where the design system has inline-flex. Only the empty variant is wired - the failed and loading variants arrive with the markup that would reveal them. The label comes from translations.rdf under the block-state-empty nodeID rather than sitting as English in the stylesheet.
… ixsl:onchange templates. onSubjectTypeChange and onSubjectValueChange were wired by addEventListener from mode="ldh:RenderRowForm", so a listener existed only on the elements that mode had walked and had to be re-attached every time ixsl:replace-content destroyed the fieldset it was attached to - which is what the re-walk at the end of the fieldset refresh is for. Saxon-JS delegates ixsl:on* modes at the document and matches the event target against the pattern, so the templates apply to any matching control whenever it appears, and a replaced fieldset needs no re-walk to stay live.

The two sequential loops in the type handler collapse into one pass over the union of the subject and object names. The pairs are disjoint - {su,sb} against {ou,ob} - so no input the pass renames can be re-matched by it, which is also what makes IXSL's deferred pending updates equivalent to the JS mutating in place and the second loop reading the result. Values are read as ixsl:get(., 'value') rather than @value throughout: the visible subject input is user-typed and the hidden old inputs are written as properties, so neither is reflected in the attribute.

The RenderRowForm template over fieldset//input stays for the autocomplete it also sets, minus its now-answered TO-DO. functions.js loses half its remaining lines; what is left there is fetchDispatchXML, which exists because ixsl:http-request cannot build multipart FormData, the OpenLayers event bridge, and the DOMContentLoaded block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… other half becomes an ldh:CloseDropdown mode. Its typeahead loop set autocomplete="off" on inputs that already carried it - bs2:Lookup passes autocomplete=false() to Web-Client's xhtml:Input, which emits the attribute, and the RDFa editor's inputs both declare it inline and use a typeahead-input class that never matched the token the loop selected on. The one input that did lack it, the remote RDF document lookup in the add-data modal, is rendered client-side long after DOMContentLoaded has fired, so the loop could not have reached it either; it now declares the attribute like the two sibling lookups in the same file.

Closing drop-downs was still load-bearing - nothing else drops .btn-group.open, since the toggle in client.xsl only flips its own group and the body handler in view.xsl dismissed facet and links popovers only. The three behaviours that were riding on the listener become applications of one mode, at the three points where a drop-down stops being current: another one opens (the toggle), a click lands outside it (the body handler, beside the two dismissals already there), or a menu pick mounts a modal (ldh:ShowModalForm, the single place any modal is appended).

This narrows coverage in one respect. The listener was native and ran on every click, while Saxon-JS delivers an ixsl:on* event to exactly one template - the target, or the nearest matching ancestor - so a click on a control that has its own onclick template and is neither a drop-down nor a modal path no longer closes an open drop-down, body never having fired. The facet and links popovers this now sits beside are already governed by that same rule, so the behaviour is consistent with its neighbours rather than newly broken, but it is a difference from the JS and has not been checked in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ses the gap left by moving the closer into IXSL. Saxon-JS delivers an event to exactly one template rule - the target, or the nearest ancestor matching it - so the body ixsl:onclick rule only ever sees the clicks no specific handler took, and pressing something that has its own handler left a drop-down hanging open. The new rule rides on pointerdown, which no other template claims, so the walk up from the target always reaches body. The comment says to keep it uncontested: a more specific ixsl:onpointerdown rule would shadow this one for its subtree.

The containment test is what lets a toggle still close its own group. pointerdown precedes click, so dismissing unconditionally would have the toggle re-open on click what the press had just closed - the same reason the JS this replaces tested !group.contains(target) behind a setTimeout. Keyboard activation raises click with no pointerdown before it, so that case stays with the body click handler, which is why the ldh:CloseDropdown call added there is kept rather than folded in here.

Measured rather than reasoned, on an isolated Saxon-JS harness driven by Playwright: a click on a span inside a handled button reaches the button's rule, so dispatch walks up; a click on that button raises no body rule, which is the limitation; an empty template terminates the walk, which is what the facet-pop rule in view.xsl relies on; and a pointerdown reached body from every target tried. Two offered refinements died there - right-click raised neither event, so the button=0 guard was dropped as guarding nothing, and Enter on a focused control raised click with no pointerdown, which is what kept the body click rule alive.

Then A/B'd against the real toolbars on the dev instance, with the SEF served from disk past Varnish, which serves /static/ immutable for a week and had been handing back a stale copy that masked the difference entirely. Without the rule, pressing btn-copy-uri or the block links toggle left the Create drop-down open while presses on plain areas, on its own toggle and on another toggle dismissed it. With the rule all five dismiss, and the toggle still closes its own group rather than re-opening it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…nd the body click rule gives up a line it never used. Instrumenting the four closing paths and driving the real toolbars showed which of them still earn their place: every mouse press is taken by the pointerdown rule, which reaches the target's handler and the ones above it alike, so the toggle's close-others loop no longer fires for a press - but it is the only path that fires for a keyboard Enter on a toggle, where there is no pointerdown at all, so it stays. The ldh:CloseDropdown call in the body click rule fired in none of the six scenarios: pointer input never reaches it, and keyboard activation of a control that has its own handler stops at that handler. It goes, leaving the rule to the facet and links popovers it was written for. That the call was unreachable is a measurement over the cases tried, not a proof, so the comment says why it was dropped rather than claiming the case cannot exist.

The same run caught a regression from the commit that introduced ldh:CloseDropdown. Picking an item from the Create menu opened its modal and left the drop-down standing behind it, because ldh:ShowModalForm is not the single place a modal is appended, as that commit claimed: ldh:render-add-modal-form appends its own, and so do five other sites. Enumerating the ixsl:append-content instructions whose content is a div.modal gives seven mount points, and the dismissal now runs at each, so the invariant is that mounting a modal dismisses the chrome that opened it rather than that one function does.

It cannot be folded into the pointerdown rule by relaxing the containment test, which was tried and measured: dismissing on press hides the item between pointerdown and mouseup, so no click is delivered to it and the modal never opens - the pick is swallowed, which is why the JS this replaces deferred its close behind a setTimeout registered on click. The close has to follow the handler it belongs to.

Verified against the running instance with the SEF served past Varnish, which caches /static/ immutable for a week and had masked an earlier A/B entirely: presses on a handled control, on a plain area, on the toggle itself, on another toggle, a keyboard Enter on another toggle, and a menu pick all dismiss, and the pick opens its modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…at went around it stop doing so. The function already existed as the client-side override of the Web-Client stub whose server-side counterpart is the com.atomgraph.client.writer.function.UUID extension function, but its body was a call into a hand-written generateUUID, and seven of its eight potential callers skipped it to make that same call directly. Those become ac:uuid(), which is what the rest of the codebase already uses for ids, scratch URIs and authorization document names, in both the server and client pipelines.

The body becomes crypto.randomUUID(), which retires the UUID.js the page loaded for that one function - the Web-Client copy that layout.xsl pulled in, and the unreferenced duplicate LDH kept beside it, which no script tag ever pointed at. It stays an ixsl:call, since XSLT has no random number generator, so this trades a script we maintain for one the browser ships rather than removing the interop. randomUUID is a secure-context API and is undefined on an insecure origin, which the comment records; LDH is served over https, and the running instance reports isSecureContext true with randomUUID present.

query-transforms.xsl needed the ac prefix and its entity, having had no reason to bind them while it was calling through ixsl.

Checked against the real toolbars with the SEF served past Varnish: the constructor modal, which reaches ac:uuid() on the client, renders with no page errors, and twenty-three UUID-shaped ids are present in the rendered DOM. The script tag removal itself is not exercised there - layout.xsl is baked into the image rather than bind-mounted, so the deployed page still loads UUID.js - but nothing references generateUUID after this: not LDH's stylesheets, not Web-Client's, and not the remaining functions.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…mponent, and the mode it never had stops pretending. Bootstrap 2's stylesheet is gone, so ul.nav.nav-tabs rendered as a bare bulleted list; the strip is now div.ldhc-tabs wrapping a va-line tablist of button.ldhc-tab and the results container, which carries role=tabpanel and is what both tabs point their aria-controls at. LDH's core.css was missing the whole .ldhc-tabs block - its copy stopped exactly where the design system's grows a Tabs section - so that section is appended verbatim and the two files line up again. The discriminator class loses its Bootstrap nav- prefix along the way; it appeared in three places, all in this file.

Two things the old markup carried were dead. $active-mode was a literal ChartMode, so the xsl:if that would have marked View active compared against &ac;ViewMode - a URI that appears nowhere else in the codebase, nor in the ac ontology - and could never have fired; it goes, and Chart is stamped active directly. The View tab is offered only for SELECT, because rendering one wraps the query into a DESCRIBE subquery and SPARQL admits only SELECT subqueries; for anything else the strip used to render a single tab with nothing to switch to, and now renders no strip at all. The results container is built once in a variable so both branches emit the same element.

The activation dance that was copy-pasted into both onclick handlers becomes an ldh:ActivateTab mode applied to the clicked button, which also repoints the shared panel's aria-labelledby. It skips itself when clearing the others, and not for tidiness: ixsl:call on classList applies at once while ixsl:set-attribute is queued to the end of the transform, so clearing this tab and then re-setting it would leave the outcome to the order updates are applied in. The component's roving tabindex and arrow-key navigation are not ported - both buttons stay natural tab stops, so neither goes keyboard-unreachable, which half of that model would not give.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…do it the way the document-level Create button does. The two onclick handlers had grown a copy each of the same mechanism - mint a fragment URI, run SetResourceID over a hand-built constructed-doc, pull out the resource and its types, then drive a ten-step promise chain - so the pair collapses onto ldh:CreateBlock in client/block.xsl, which runs the chain form.xsl already had: fetch the class's SPIN constructor, instantiate it, load constructors and shapes, render the row form. ldh:render-block-row-form goes, because ldh:render-add-row-form was the same function with a different insertion point; that point is context now, stamped per flow as insert-anchor plus insert-position - the dock inserts before its own container, a block after itself.

Reusing the constructor rather than hand-writing the instance is what the change is for. A chart's create form no longer carries a hand-rolled dct:title slot: title and description arrive from ldh:TitleConstructor and ldh:DescriptionConstructor the way they do everywhere else, and a view picks up ac:mode, ldh:container and ldh:showWhenEmpty besides. Each flow supplies only what binds the new block to its query - spin:query for a view, and for a chart the type, category and series read off the chart controls. The merge that folds those in knows no predicate names: the value comes from the caller and the type from the constructor's own marker bnode, so a literal inherits the datatype the constructor declared for it. A property the caller leaves blank hands over nothing and so displaces nothing, which matters because bs2:FormControl builds a literal's input from its text() node - taking the slot over with an empty value loses the control instead of pre-filling it.

The chart's Create button had not worked for some time, here or on master. Its rule asked for div[contains-token(@Class, 'block')][@about][@typeof], and bs2:Row has had the typeof attribute commented out since at least the start of August, leaving @about on the block wrapper and @typeof on the inner .block-row - no single ancestor carries both, so the rule never fired and the click did nothing. Instrumenting the rule and driving the real page showed it was never entered; the predicate goes.

Both buttons are shown only to an agent who can act on them. Creating POSTs a new block into the current document, so the Create buttons follow acl:Append as the other creation UI does, and the Save buttons on the query block and on the chart follow acl:Write, saving being a PATCH of the current document. Neither was gated before.

The rest is placement and naming. The view's onclick handler sits in view.xsl beside the other view templates, mirroring the chart's in chart.xsl, and the two are now the same seven lines around a ldh:CreateBlock call. The tab-selection mode is ldh:SelectTab: ldh:ActivateTab was already taken by client.xsl for activating a document tab in the tab bar, where it also re-syncs that pane's acl:mode() flags, and the two have nothing to do with one another.

Driven in a browser against a SELECT block: the tab strip renders both tabs, clicking View flips aria-selected and repoints the panel's aria-labelledby, the view renders, and Create yields a row form with spin:query filled in and the constructor's remaining slots editable. The chart side yields one control per predicate, carrying over whatever the chart controls held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…'s before()/after(). The TO-DO on that line waited on SaxonJS 3 for ixsl:insert-before and ixsl:insert-after (saxonica.plan.io/issues/5543); the runtime we ship implements both, so it is discharged. Which of the two applies is stamped into the context by each flow as a lexical QName and reaches @method through an attribute value template, so the shared function inserts without branching - the dock stamps ixsl:insert-before to land the form above its own container, ldh:CreateBlock stamps ixsl:insert-after to land it below the block.

The other before()/after() call sites stay as they are, because the two are not interchangeable: xsl:result-document constructs a result tree, so a node named in its content is copied, while the DOM methods move the node that already exists. Nothing here notices the difference - the row form is built by XSLT and re-acquired afterwards as the anchor's sibling rather than through the variable - but the rdfa-editor sites are the opposite case throughout. rdfae:element() is document.createElement, and the code goes on using the variable after inserting it: setting contenteditable on it, testing its parent::, injecting chrome into it, placing the caret in it. Copying would leave all of that pointed at a detached orphan. The two in block.xsl move an existing block for drag-and-drop and put it back when the PATCH fails, where a copy would duplicate it. Converting any of them means restructuring the site to build a tree and re-find the node, which is a change to behaviour rather than a substitution.

Not yet exercised in a browser: whether SaxonJS honours an attribute value template on @method is a runtime question, and the deployed build predates this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…e the earlier runs had skipped. Every chart run so far had happened with no category and no series selected, which took the valueless branch and never reached the code that fills a literal slot; and no run had ever submitted the form it checked for, only looked at it.

Filling a literal slot wrote the datatype after the value. A property carrying a literal holds it as a text node, so copying @* | node() and then adding rdf:datatype appends an attribute after children, which is an error - the alert named ldh:categoryVarName as the start tag it could not write to. Attributes now go first and the value follows.

Saving the created block sent IXSL:INSERT-AFTER as the HTTP method. ldh:render-add-row-form already had a $method - the form's own method, post - and naming the insertion method $method too shadowed it, so bs2:RowForm was handed the serialization method and put it on the form. It is $insert-method now, with a comment saying why it cannot share the name. The same function shadows query-string and request-uri three times each for its three metadata fetches; that is pre-existing and left alone, but it is the same hazard.

Driven end to end with a category and four series selected: the row form comes back with categoryVarName and every seriesVarName carrying its value and lt=string, chartType and spin:query filled from the caller, title and description empty from the constructor; saving it posts, succeeds, and the block re-renders through ldh:render-chart to chart-results-response, with no alert at either step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…save shows the violation instead of an XSLT error. Typing a SELECT into a block that was an sp:Ask and saving PATCHed the new text onto a resource still typed sp:Ask, which the server rejects with 422 - correctly, since the type and the text disagree. The handler had computed the query form from the text all along and then dropped it on the floor: query-type was declared and never referenced. It is used now, and the type of the four query forms travels with the string into the same rewrite.

The 422 was invisible because the client crashed rendering it. ldh:render-row-form-violation reads context('form'), which the flows that PATCH from a button rather than submitting a form - saving a query, saving a chart - never put there, and passed the resulting empty sequence into bs2:RowForm's required xs:string method parameter. So every violation this branch exists to display turned into "Supplied value for parameter $Q{}method could not be converted to required type". It falls back to the method of the request that was rejected, and then to the parameter's own default.

ldh:Identity is retired in favour of two modes, one per save. It was a single mode carrying templates from both query.xsl and chart.xsl, so each save walked the other's rules; adding the retyping to it would have put a query-specific rewrite in the chart's path. ldh:replace-query and ldh:replace-chart each own their identity template and see only their own resource. That also closes a latent trap: sp:text/text() declared query-string as a required tunnel parameter on the mode the chart save walked too, which held only because no chart resource carries an sp:text.

Not verified in a browser: the server was restarting when this was committed. The reproduction is scratchpad asksave.py - SELECT text saved onto the sp:Ask block - which showed PATCH 422 and the alert before these changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…e letting it proceed is worse. Falling back to the rejected request's method removed the type error and unblocked the path behind it, which resubmits in a loop: one rejected query save drove roughly 130 PATCHes and as many alerts, and since the save is a DELETE/INSERT one of the resubmits landed a 204 with nothing on the insert side and removed the query resource altogether. The loop was there before and unreachable only because the type error aborted ahead of it. Failing loudly and early is the lesser harm until the resubmission is fixed, and the line now carries a comment saying so, so the next person to notice the empty sequence does not quietly restore the data loss.

What stays from the previous commit is the retyping, which is verified: typing a SELECT into a block typed sp:Ask and saving now returns 204 rather than 422, and the store holds sp:Select against the new text.

Two things this exercise turned up, both older than this branch and neither addressed here. The resubmission loop above. And btn-save-chart, which fires - the busy cursor is set - and then dies before issuing a request: it binds query-uri from $container//*[@Property = 'spin:query']/@resource as a required xs:anyURI, and a rendered chart block carries no such node, though the stored charts all have spin:query. That is the same shape as the btn-create-chart rule that this branch already fixed, and it leaves ldh:replace-chart reachable only in principle - the rename compiles but no UI path exercises it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…nger cut off its head. The overlay was already built with autoPan set, and it had never once panned: ol.Overlay runs autoPan when the overlay joins the map, and addOverlay happens while the xsl:result-document that fills the container is still a pending SaxonJS update, so panIntoView measured an empty div, found that zero-height rect comfortably inside the viewport, and declined. The content arrived a moment later and nothing looked again. A popup anchored high on the map therefore grew upwards past the top of ol-viewport, which OpenLayers gives overflow hidden inline, and the cut landed exactly where the view toolbar ends - close enough to read as the controls clipping the dialog. The pan is re-run from an ixsl:schedule-action, which fires after the pending updates are flushed and so measures the filled container; max-height 75% on ol-overlay-container keeps it small enough for the 20px margin to fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…ing the previous commit, whose reading of the fault was wrong twice over. Instrumenting ol.Overlay in a real browser showed the container already carrying its 3070 characters at the moment addOverlay runs, so there was no pending SaxonJS update to defer around, and the panIntoView added last time never fires at all: the two recorded calls both arrive with ol's own normalised autoPan argument, and none with the empty one that template passes. ol had been panning correctly the whole time and simply could not pan far enough. The map sat at the north edge of Web Mercator - the viewport's top edge measured 20037508.342789244, the projection's half-extent to the digit - so the view's centre constraint stopped the pan 147 of the 438 pixels it needed, and overflow: hidden took the rest of the popup's head off just under the view controls. No pan can win that: at the edge of the world there is nowhere left to go. So autoPan goes, and once the overlay is on the map and wearing the panel's width and max-height, its own box is measured against the map's: the popup opens above the marker while there is room, drops below when only that side has room, and any remaining overhang is taken out with setOffset, on both axes - a marker in the left 30% of the map pushes the 60%-wide popup off that edge by the same mechanism. Unverified in the running app: it serves a SEF compiled from linkeddatahub.com/files/client.xsl, which imports this one, and that has not been rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…h children of .parallax-nav are flex items of a wrapping row, and the chip host was itself a flex container, so its base size was the max-content width of the whole unwrapped chip line; once the chips outgrew the row that item wrapped as a unit, leaving the glyph alone on the line above and the chips wrapping among themselves below it. Dissolving the host with display: contents hands the chips to the row, whose align-items, flex-wrap and gap were already the ones the host was repeating - the same treatment the facet pills get from .ldh-view-toolbar .left > .facets, which is why the funnel has always sat level with its pills. Measured in the browser afterwards: glyph and first chip share a centre line at y 208, and the chips start at x 412, just past the glyph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
… instead of stacking on it. The pan is the behaviour every popup implementation shares - Google's InfoWindow, Leaflet's Popup, ol's own autoPan - and none of them centre on the marker: they move the least they can to reveal the panel, which keeps its foot on the pin. So the shift computed for the fitting is offered to the view first, through getConstrainedCenter, which answers with the centre it can actually reach; the pixel that centre occupies in the frame the measurements came from says how much of the shift the pan absorbed, and only the remainder - the part the projection extent refuses at the edge of the world - becomes the overlay's own offset. The stacking goes the way Leaflet settles it, whose Popup autoCloses by default: info windows are the only overlays this map carries, so the collection is cleared before the new one joins.

Committed on request, and it does not work: with both changes in, clicking a marker blocks the renderer's main thread - no info window appears and CDP itself times out reaching the DOM. Which of the two hangs is not yet known, so do not deploy this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…l hands coordinates and pixels back as two-element JS arrays, and SaxonJS delivers those as sequences of two doubles, not as XDM arrays: declaring one as array(*) and reaching into it with array:get does not raise a type error, it locks the renderer's main thread, and the click that should open an info window never returns. Message probes through the function put it exactly there - everything up to the constrained centre reported correct values and the statement after it never reported at all - and a count of 2 with array? false, doubles? true named the type. Indexing the sequence is all it needed. Reading it with ixsl:get instead hangs the same way, so the comment says which of the two shapes this is.

Verified in a browser against the running app, on both paths the placement has to cover. A marker 87px from the top of the map: the panel flips below it, no pan needed, clippedTopBy 0 and the close button back in view. A marker in the band where neither side has room: the view had somewhere to go, so the pan absorbed all 174px of the shift, the offset stayed at zero and the panel kept its foot on the pin, 19.75px clear of the top edge. The second click also left exactly one overlay on the map and one .ol-overlay-container in the DOM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant