Skip to content

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

Merged
namedgraph merged 145 commits into
developfrom
rf-design-system
Sep 2, 2026
Merged

Design system port: replace Bootstrap 2 with the LinkedDataHub design system#371
namedgraph merged 145 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 29 commits August 30, 2026 18:56
…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
The client re-rendered the document body over markup the server had already
produced. Because the client SEF is compiled at build time and carries no
package stylesheet imports, that re-render silently dropped every package
rendering rule the server had applied — the SKOS package's suppression of
skos:broader in bs2:PropertyList being the visible case. Package rendering
worked for crawlers and not for humans.

Stamp server-rendered at the ldh:DocumentNavigate call in the main template —
the only flow where a server-rendered body is already in the DOM — and thread
it to ldh:rdf-document-response the way refresh-content already travels. There,
skip the document-body replacement when the pane already shows this document.
Not stamped for proxied URIs: ProxyRequestFilter returns the local shell, so no
body for the external resource was rendered. The error path still replaces the
body, and ldh:RenderTab still runs, so the content-block factories populate the
pane as usual.

Skipping the replacement alone lost the ontology-driven view blocks. The
injector in client/block.xsl keys off the wrapper nesting
(div.block[@about] / div.row-main / div.block[@typeof]), which resource.xsl
emitted only under a use-when gate admitting it to the SaxonJS build. Server
markup was flat, so the injector could not match. Drop the gate so both products
emit the same shape; the injection stays client-side and now runs over
server-rendered markup too.

Fixing the shape at the source rather than teaching the injector a second shape
is deliberate: the wrapper passes both @about and @typeof to the inner element,
so a selector broadened to match the flat shape would also match the wrapped
one and inject every view block twice.

Verified in Chromium against a running instance, SEF served by request
interception so the Varnish copy of /static/ cannot interfere, with the SKOS
package installed and SSR confirmed to suppress skos:broader beforehand:

  guard off  Belenkas: HasBroaderRow=1  Broader=1  Narrower=1
  guard on   Belenkas: HasBroaderRow=0  Broader=1  Narrower=1

One view block per concept, not two. Measured on the same document, median of
six runs to both view blocks present: 1403ms -> 1284ms.

This fixes the initial load only. Client-side navigation still re-renders from
the package-less SEF, so skos:broader reappears after navigating within the
app. Full SSR/CSR consistency needs per-application SEF regeneration, which
requires Node or Saxon-EE and stays out of reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…trol that is, and the class menu closes when a class is picked.

Scrolling down to a newly created instance was never implemented. It was the browser scrolling the focused element into view, and the only thing focusing anything was a rule matching fieldset//div.required[1]//input. Of the two properties the SPIN constraints make required on sp:Construct - dct:title and sp:text - only sp:text is rendered, because sp:Query's constructor emits rdfs:label, rdfs:comment, sp:text and ldh:service and no title; and sp:text renders as a textarea, which //input does not match. So query forms focused nothing and stayed where they were, while lapp:Application and sd:Service focused their lapp:origin and sd:endpoint inputs and rode the browser's scroll down to the new form.

Widening that pattern to textarea alone would not have worked either way round. block/query.xsl already claims the same textarea in the same ldh:RenderRowForm mode at the same priority and the same import precedence, to build the YASQE editor on it, and of two such rules only the later-included one runs - so whichever won, the other would have been lost. And CodeMirror.fromTextArea sets the textarea's display to none, so focusing the element is a no-op however the rules resolve. The focus therefore moves up to the control-group, which nothing else claims, and dispatches through a new ldh:FocusControl mode after the group's own children have run: by then the widget exists, and block/query.xsl overrides the mode to focus the editor instance in place of the element it replaced.

owl:NamedIndividual constrains nothing, so a bare instance form has no required group and nothing to focus at all. Its type control takes the focus instead - picking the class is the only thing left to do on that form - matched through the hidden RDF/POST predicate input the group carries, which the property picker beside it does not have. Which control the focus lands on is one function shared by both arms, and it also accepts the button a typeahead renders when its value already resolves, where the old rule found only a hidden input.

The menu the class was picked from stayed open because the page-wide dismissal is a body pointerdown rule that deliberately skips any group containing the press, which exempts every menu item. It is closed from the pick's own handler, on the click rather than when the instance lands: the chain that renders it takes four sequential round-trips and a menu left standing across them reads as a dead click.

The query editor also needed a width of its own. YASQE's div.CodeMirror declares none, so as a flex item in the controls row it was sized by the query text, and an empty query left nothing but the gutter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…agged literal is no longer collapsed into an untagged twin.

ldh:MergeRDF grouped a description's properties by predicate plus (@Rdf:resource, @Rdf:nodeID, string(.))[1] and kept current-group()[1], so two literals differing only by @xml:lang or @Rdf:datatype shared a key and the later one was dropped. "Concept" and "Concept"@en are distinct RDF terms; the merge treated them as duplicates.

Ontology terms routinely carry both: the vocabulary labels its own term without a tag - dh.ttl has rdfs:label "Item", the bundled PROV-O has rdfs:label "Person" - while the app ontology adds tagged ones. For skos:Concept the /ns metadata query returns "Concept", "Concept"@en and "Sąvoka"@lt, the first two collided, and the untagged one won by document order. The English label never reached the stylesheet, so ac:object-label negotiated over what was left and the type chip rendered "Sąvoka" for a browser asking for en-US,en,da,lt. The failure needed two accepted languages to be visible: with en alone the untagged label satisfied the no-match fallback and the chip read "Concept", which looked correct and was not - it was the local name, not the English label.

Adding @xml:lang and @Rdf:datatype to the key keeps all three. Replaying the real /ns response through the merge and the rdfs.xsl ac:label templates: en, en+de, de+fr and en-US+en+da+lt now yield Concept, lt and lt+en yield Sąvoka - the first-listed accepted language wins in every case, and untagged values survive alongside their tagged siblings instead of evicting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… hiding the ones that don't match it.

Two templates suppressed values by language tag: one dropped any value in a language not in $ac:langs, the other was meant to drop values outranked by a more preferred language on the same property. The second never ran. Its rank subexpression, (for $i in 1 to count($ac:langs) return $i[lang($ac:langs[$i])])[1], puts a predicate on an integer, so the context item for the one-argument fn:lang is xs:integer and the call raises XPTY0004 - Saxon-HE says "The context item for lang() is not a node". In a match pattern that error is swallowed and the template simply never matches, which is why it went unnoticed: with a single accepted language the first template alone collapses a property to one value, and a hand-written Accept-Language: en reproduces exactly that. Real browsers do not send one language. With en-US,en;q=0.9,da;q=0.8,lt;q=0.7 the server's $ac:langs is (en, en, da, lt) - XSLTWriterBase builds it from the full q-sorted getAcceptableLanguages() - and a SKOS concept rendered both prefLabel rows, server-side and client-side alike.

Suppression is the wrong strategy here regardless. Negotiation belongs where exactly one string can be shown - ac:label for headings, breadcrumbs and link text, xhtml:TableDataCell for one value per cell - and both already do it correctly. A property list answers "what does this resource assert", so there is no conflict to resolve: withholding values puts the HTML at odds with the Turtle served from the same URI, silently and without a count. For SKOS it guts the page, because a concept's multilingual labels are its content. The first template's other failure follows from the same subtractive design: a value in an unaccepted language was dropped even when the property had nothing else, so an English browser saw an empty property where the only label was Lithuanian. Ordering never has to answer "what if nothing matches", and its worst case is a wrong order rather than missing data.

So both templates go, and ldh:lang-rank becomes a sort key between the existing property-label and object-label keys - in the bs2:PropertyList override in resource.xsl and in the three diff blocks in layout.xsl. Untagged values and unaccepted languages rank last and sort after the accepted ones rather than vanishing. The function takes its node as an argument, which is what the dead pattern could not do.

Verified as a sort key on both engines, since SaxonJS sort keys have bitten us before (982e672, where a NaN key froze the comparison and killed the secondary key): ldh:lang-rank returns xs:integer unconditionally, comparison is numeric rather than lexicographic - rank 2 sorts before 11 - and a rank tie still falls through to the object-label key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ive the language tag a pill.

5bd46da removed the Bootstrap 2 stylesheets and left ldh-bridge.css carrying replacements for the primitives LDH still emits. What it missed is the vocabulary that got no replacement: classes still written into the markup that no rule anywhere matches. They are not merely inert - .label now means the property-group label column in app.css, so "label label-info" on the language tag collided with a live class while contributing nothing, and the tag rendered as bare text glued to the value: "Squareen".

Scoped by measurement rather than by name - a class goes only if no loaded stylesheet has a rule for it:

  label label-info   0 rules  -> chip-inline on the language tag, plain checkbox in modal.xsl
  help-block         0 rules  -> help-inline, which ldh-bridge.css does style for form hints
  dl-horizontal      0 rules  -> dropped; .ldh-block dl styles the element itself
  input-prepend      0 rules  -> dropped
  add-on             0 rules  -> dropped
  span1 / span11     0 rules  -> dropped; the grid they belonged to is gone
  alert-success      0 rules  -> dropped, .alert and .block-row stay
  icon-warning-sign  0 rules  -> Material Symbols span, as everywhere else

Kept, because the bridge does style them: control-group (18 rules), btn-group (9), control-label (8), checkbox (4), badge (2), nav-tabs (2), hero-unit (1), alert (8) and alert-error. Removing an unbacked class is behaviour-preserving by construction - nothing was matching it - so the only visible change is the language tag, which now needs to be legible: a property renders every language it carries, so the tag is what tells its values apart. It gets the design's own inline pill, written against .ldh-block dl > div > dd because that is the structure bs2:PropertyListIdentity emits; the app.css .chip-inline rule is scoped to .ldh-prop-row .value, which appears nowhere in the rendered page.

One piece of stale naming left alone: block.xsl still calls a variable $span12, though it selects .row-main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… in one language is not served to a reader who asked for another.

Every representation this filter sees is rendered by XSLT that negotiates over $ac:langs, which XSLTWriterBase builds from the full q-sorted getAcceptableLanguages(). The content therefore depends on Accept-Language. The Vary header did not say so.

Core's Response selects a variant with request.selectVariant(variants) and, when that returns null because no declared language variant matched, retries with selectVariant(removeLanguages(variants)). Jersey derives Vary from the variant list it was handed, so the fallback advertises a cache key with no language dimension. Measured against a running instance, cache-busted so every request reached the backend:

  Accept-Language: en                              Vary: Accept-Charset,Accept,Accept-Language
  Accept-Language: lt                              Vary: Accept-Charset,Accept
  Accept-Language: en,lt                           Vary: Accept-Charset,Accept
  Accept-Language: en-US,en;q=0.9,da;q=0.8,lt;q=0.7  Vary: Accept-Charset,Accept

Only an exact match on the one declared language got it right; every real browser header did not. Since the bodies demonstrably differ - heading, type label and property values all follow the accepted languages - any shared cache was free to hand a Lithuanian rendering to an English reader. That is how an Age: 146 response turned up during this investigation carrying an English heading beside a Lithuanian type chip.

Adding the dimension here rather than in Core keeps it with the rest of LDH's response headers. Only responses that already carry a Vary are touched, and Vary: * is left alone.

Worth knowing before this reaches a busy cache: Vary: Accept-Language keys on the raw header, and browser values are near-unique, so hit rates drop. Normalising the header to a supported-language shortlist in the Varnish VCL before the hash is the usual remedy, and is not part of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…endering in one language is not served to a reader who asked for another."

This reverts commit efe5be0.
…der, so they follow the reader's language like every other label.

A document read in a Lithuanian browser showed its values, heading, type and object links in Lithuanian and its predicates in English: "Is in scheme", "Notation", "Preferred label". Those strings are not the app ontology's - LTLOD's ns.ttl says "Priklauso schemai"@lt and "In scheme"@en - they are the SKOS vocabulary's own labels, "is in scheme" and "preferred label", capitalised by ac:property-label.

ac:property-label resolves a predicate in three steps: the current document, then $property-metadata, then a dereference of the vocabulary namespace. Published vocabularies label their terms in English, so step three is English whatever the reader asked for. Step two was empty, because nothing computed $property-metadata for the read-mode render: the DESCRIBE against /ns is set up in xhtml:Table, in bs2:Form, and in the proxied-profile diff, and nowhere else. The client has had the property half of the pair since its metadata chain was written - block.xsl says as much, "mirror the object-metadata pair above, but DESCRIBE the property URIs" - but the server only ever built the object half, so the initial render fell to the vocabulary and the labels were English for everyone. It looked correct as long as the reader was reading English.

So xhtml:Body now builds $property-metadata beside $object-metadata and tunnels it, with a $property-metadata-query mirroring client.xsl's. The predicate labels then resolve exactly where the object labels already do.

Tunnelling alone could not have fixed this: Web-Client's default-mode property template resolves its label through the one-argument ac:property-label(.), and tunnel parameters do not cross an xsl:function boundary - confirmed on Saxon-HE and SaxonJS both. LDH's override of that template already sidesteps it by taking the tunnel itself and calling the two-argument form when the metadata is there; it just never was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…document's.

A property renders every language it carries, ordered by the reader's preference, so two values with different tags sit side by side under one document language. At most one of them can be right. Without a lang attribute a screen reader reads "Square" with Lithuanian phonetics on an lt page, and "Aikštė" with an English voice on an en page - and the same inheritance drives hyphenation, :lang() selectors and the spell-check dictionary in editable fields. That is WCAG 3.1.2 Language of Parts, and it was failing in both directions.

Three cases, following what RDF actually says about each literal:

  @xml:lang present            lang with that tag
  plain literal / xsd:string   lang="" - HTML's "unknown", the exact counterpart of RDF's absent tag
  other datatype               nothing

The empty value matters as much as the tag. An untagged literal makes no language claim, so letting it inherit the document language has the page asserting something the data never said - the same reason Content-Language must not simply default to the server's language. A number or a date, on the other hand, is not prose: those inherit deliberately, because you want 42 and a formatted date read out in the reader's language. XHTML literals are skipped for the same reason - they are markup and carry their own lang where it matters.

The marking is unconditional rather than emitted only when the value differs from the document default. That keeps the markup independent of whatever <html lang> ends up being, so the root can change later without revisiting any of this.

Verified across all seven literal shapes: lang-tagged (both directions), untagged, explicit xsd:string, xsd:integer, xsd:dateTime and rdf:parseType="Literal". Falls out as a bonus that the RDFa is now faithful - the extracted literal keeps its language tag, where before it was flattened to a plain literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ng it claims to.

48 of the 208 entries carried only an en-US label, so a Spanish reader got a page in mixed Spanish and English: the chrome translated where a translation happened to exist and fell through to the en-US branch of rdfs.xsl where it did not. The gaps clustered in the parts added since the original translation pass - the footer, reconciliation, container generation, the empty and error states, and the RDF serialisation names.

This also settles a question that was about to need a policy. Deriving the supported UI languages from this file rather than from the ldhc:supportedLanguages config only works if "has a label in language X" is a reliable answer, and at 77% coverage it was not - "any tag present" and "fully translated" gave different answers for Spanish. At 100% they agree, so the derivation needs no coverage threshold and no arbitrary cutoff.

Serialisation names are kept as-is (RDF/XML, Turtle, JSON-LD, HTML, SPARQL, Literal, Constructor), matching how ASK/SELECT/DESCRIBE/CONSTRUCT and AtomGraph already carry an es-ES label identical to the en-US one - the label exists so the lookup resolves, not because the string differs. Endpoint stays "punto de acceso" and Remove stays "Eliminar", following the entries already in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…d of declared beside it.

ldhc:supportedLanguages was a hand-maintained claim about what translations.rdf contains, and the two had drifted: the config said "en,es" while the bundle is tagged "en-US,es-ES". Worse, that config was then used for something it never described - as the language dimension of JAX-RS variant negotiation, and so as the source of Content-Language, which is why a page containing no Spanish came back as Content-Language: es. The bundle is the only thing that knows which languages the UI can actually be rendered in, so it is now the source, and the config is gone.

The parse is a plain Jena read of the bundle, collecting the distinct language tags of its labels, sorted so variant selection is deterministic. Untagged labels contribute nothing - they are not a language the UI can be rendered in. A language counts as supported if the bundle carries any label in it; with the bundle now fully translated that also happens to be the same answer as "fully covered", so no coverage threshold is needed.

The reading is split into a ServletContext overload and an InputStream one so the derivation can be tested without a container.

One finding worth recording, because the test caught it before it shipped. The derived tags are region-qualified while Accept-Language arrives as primary subtags, so matching them needs RFC 4647 Basic Filtering, not Lookup. Lookup truncates the range and returns only tags equal to or less specific than it, so the range "en" does not match the tag "en-US" - Locale.lookupTag returns null for exactly the case we need. Locale.filter matches in the direction the bundle requires, and the test asserts both halves so the distinction cannot quietly regress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…agrees with it by construction.

<html lang> came from $ac:lang - the reader's top preference - and never consulted what the page contained, so Accept-Language: de produced lang="de" on a page whose every word was English, and es produced lang="es" on that same page. Content-Language came from somewhere else entirely, JAX-RS variant selection, which matched only when the reader's top preference happened to be an offered language: it announced es on a page holding no Spanish, and said nothing at all on a page that was entirely Lithuanian. Two mechanisms, two different wrong answers about one document.

Both now come from Application.getEffectiveLanguage: the highest-ranked accepted language the UI bundle actually provides, falling back to the first supported language when the reader accepts none. One computation feeding the writer's $ac:contentLang param and the response filter's header, so the two cannot drift apart - the alternative was two implementations agreeing, which is the failure mode this whole change is about.

$ac:lang and $ac:langs are untouched. $ac:lang still means the reader's top preference, which is right for the ~30 xsl:sort lang= collations it drives: sorting Lithuanian labels under English rules would misplace Ą, Č, Š and Ž, and that would have been a silent regression from a change that looks like it only touches one attribute. $ac:contentLang defaults to $ac:lang, so a Web-Client app supplying no value behaves exactly as before - with no bundle to negotiate against, composed and requested are the same thing.

Matching is RFC 4647 Basic Filtering through Locale.filter rather than Lookup, for the reason the previous commit records: the bundle is tagged en-US/es-ES while Accept-Language arrives as en/es, and Lookup does not match that direction. The tests cover a reader whose language the bundle lacks (previously the lang="de" case), preference order beating bundle order, an empty preference list, and an absent bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…he document being composed in another one.

Making <html lang> the composition language rather than the requested one moves the root for a Lithuanian reader from lt to en-US, because the UI bundle has no Lithuanian. The predicate labels do have it - they come from the application ontology, not the bundle - so they went from inheriting a correct lt root to inheriting a wrong en-US one. Left unmarked, the previous commit would have regressed exactly the labels it was meant to leave alone.

They are resolved through mode="ac:property-label" rather than the function of the same name, because the function is declared as xs:string? and drops the winning literal's tag at its own boundary. The mode returns the label node, so ../@xml:lang is still reachable.

Bound as item()*, not node()*. The fallback branches return computed strings rather than a node - substring-after($this, '#') for a predicate the ontology does not describe - and binding an atomic value to node()* raises XTTE0570 at run time. That compiles clean and fails on a real page, which is the same shape as the dead match pattern that started this work, so it is worth naming: the guard is $label[1][. instance of node()], and a computed-string label emits no attribute and inherits, which is right.

Verified both branches under Saxon: a label node yields lang="lt", a computed string yields no attribute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
getEffectiveLanguage was a pure static function parked on the JAX-RS lifecycle class, and both call sites had to spell it com.atomgraph.linkeddatahub.Application.getEffectiveLanguage(...) because each already has a different Application in scope - apps.model.Application in the response filter, and the JAX-RS one in the writer. Needing the fully qualified name is the tell that it was in the wrong place.

It is now LanguageNegotiator.negotiate in server/util, beside URLValidator, Skolemizer, SecureXML and WebIDCertGen, which is where focused helpers live. The name also stops claiming to be an accessor: get* on a two-argument static reads like a bean property and it is a computation over its arguments.

readBundleLanguages stays on Application. That one is genuinely application configuration - it populates supportedLanguages from the constructor and answers what this deployment can render its chrome in. Per-request negotiation is what did not belong.

No behaviour change; the tests are the same assertions against the new home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… it a second time.

$ac:contentLang and Content-Language were both computed by calling LanguageNegotiator, once in the writer and once in the response filter. Two computations that agree is not the same as one value, and it is the shape this whole change set has been arguing against - it is how $ac:lang and ldhc:supportedLanguages drifted apart in the first place.

ResponseHeadersFilter runs before the entity is serialised, so by the time the writer assembles its parameters the header is already on the response. Reading it back makes the document's lang attribute and the Content-Language header the same value by construction rather than by two call sites staying in step. Where no header was set - a response that never reached the filter - the parameter is simply omitted and the stylesheet default applies.

LanguageNegotiator now has exactly one caller.

This is as far as the unification goes for now. Core also negotiates, for the Vary language dimension and the entity tag, and folding that into the same single computation needs the Core-side work to be consumable here: LinkedDataHub resolves com.atomgraph:core 5.0.2 transitively through client 5.0.3, while the Core changes are on 5.0.3-SNAPSHOT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… served with.

LinkedDataHub overrides match="/" and emits the root element itself, so the lang attribute never came from Web-Client's template - it came from layout.xsl, and it read $ac:lang, the language the reader asked for. That is how a page written entirely in English went out as lang="de" for a German reader and lang="es" for a Spanish one.

It now reads Content-Language out of $ldh:httpHeaders, which the writer already populates from the response headers - the same pattern ldh:memento-datetime uses. Deriving the attribute from the header rather than recomputing the same value beside it means the two agree structurally instead of by two call sites staying in step, which is the property this whole change set has been chasing. The parameter the writer was passing for this is gone with it.

Falling back to en, never to the request: with no header to go on the honest answer is the language the chrome ships in, and falling back to $ac:lang would reinstate the defect as the default.

Also declares $ac:lang for the client-side compilation. Web-Client 5.0.4-SNAPSHOT moved $ac:langs and $ac:lang out of imports/default.xsl into bootstrap/2.3.2/layout.xsl, which client.xsl does not import, so $ac:lang became unbound and the SEF build failed with XPST0008. client.xsl already redeclares $ac:langs for the browser's list; this is the same treatment for the value derived from it.

Verified in a browser across four readers: <html lang> equals Content-Language in every case - en-US for lt, en and de readers, es-ES for the Spanish one - while the values and predicate labels keep their own lang, and Vary carries Accept-Language for all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…stop colliding.

The HTML rendering negotiates over the whole accepted-language list, falling back per value, so two requests that select the same language-neutral variant are still different representations. Core hashes the list into the entity tag when it is given one, but nothing gave it one - measured on the running instance, Accept-Language lt and de came back with identical ETag "bf450a1e36535bd0" at 25763 and 25710 bytes, so a conditional request could be answered 304 with the wrong language.

Wired on the document path, which is where the collision was observed. Verified after: lt, de, fr and en now yield four distinct tags, and repeating lt returns the same one.

de and fr render byte-identical output here and still get different tags, because the hash is over the requested list rather than over what the renderer did with it. That is deliberate - the alternative is Core inspecting the rendering it does not perform - and it costs a little revalidation efficiency in exchange for never merging two representations.

The remaining Response construction sites - the SPARQL endpoint, the proxy and the access resource - do not expose HttpHeaders and are left alone; their existing entity tags are unchanged because the parameter is additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… it.

Content-Language was set on every response carrying an entity, so an RDF/XML document came back as Content-Language: en-US. That representation is byte-identical for every reader - its literals carry their own tags and none is dropped - so it is intended for all language audiences, which RFC 9110 spells as no Content-Language at all. The header also contradicted the response's own Vary, which carries no Accept-Language dimension for exactly the same reason.

Scoped with the predicate that already draws this line. HTMLMediaTypePredicate is what decides whether language becomes a variant dimension and, since the entity tag change, whether the accepted-language list is hashed into the validator; labelling the representation is the third thing that should follow from the same answer rather than from a separate rule. It matches both text/html and application/xhtml+xml, so the negotiated HTML flavour is covered either way.

Verified per media type: HTML keeps Content-Language: en-US, Vary with Accept-Language and a language-dependent ETag; RDF/XML now has no Content-Language, no Accept-Language in Vary, and one ETag shared across readers. <html lang> is unaffected - it reads the header, and the header is still set for the representation it appears in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ther than a copy on each side of the boundary.

ldh:lang-rank and ac:lang-rank were the same expression. Web-Client needs it for
itself now that table cells order their values instead of dropping all but one,
and LDH cannot lend a function to a library it depends on, so the one that
survives is the one lower in the stack. The four sort keys here follow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
The two templates that consumed it in Web-Client have been commented out for
long enough that the parameter behind them was never declared - reviving them
would have meant adding one first. The vocabulary constant outlived the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ls responses with.

Two of these guard behaviour that fails quietly. A snapshot request must
advertise at most acl:Read however much access the agent holds on the live
document, which is what withdraws the edit affordances from an immutable
version; and a proxy response must carry none of this instance's endpoint,
ontology or stylesheet, because it describes someone else's resource and
ProxyRequestFilter forwards that application's own links instead. Both are one
boolean away from being wrong and neither shows up as an error.

The Memento cases needed a repository that is actually present - the earlier
stub returned an empty Optional, which left the whole block unexecuted - and
they pin the four RFC 7089 relations against each other: the TimeMap is the only
resource that identifies itself, the TimeGate never links to itself, and the
Original Resource is the only one that does not carry rel=original.

Since most of these assert an absence, and an absence is what a filter that does
nothing produces, the two above were checked against a mutated filter: relaxing
the snapshot predicate fails the read-only tests, hardcoding isProxyRequest to
false fails the suppression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…t can disagree.

Six scripts covering what a unit test cannot reach: that Content-Language is the
first accepted language the bundle has rather than the reader's top preference,
that it appears on HTML and not on RDF, that Vary carries Accept-Language and
two readers survive each other through the caches, that entity tags separate the
two renderings while the RDF tag stays stable across them, that <html lang>
agrees with the header, and that each value carries its own language.

The document assertions request application/xhtml+xml and query it with xmllint,
the way the sparql-protocol and federation suites already do. That is not only
tidier than matching text: the build inlines entities through an XML round trip
that alphabetizes literal-result-element attributes, while xsl:attribute
instructions append in execution order, so a fixed attribute string passes or
fails depending on which build produced the webapp.

Every probe also asserts a 200. LinkedDataHub renders its errors through the
same stylesheet, so an error page carries a well-formed Content-Language and a
matching lang attribute, and three of these tests passed against a 400 before
the status check went in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…t happens to be stored under.

Deriving the supported languages from translations.rdf brought its keys out onto
the wire with them: a Spanish reader was answered Content-Language: es-ES, and
since the root lang attribute is read back from that header, the document
claimed es-ES too. BCP 47 asks for a tag no more specific than is justified and
says a region subtag "MAY be omitted, as when it adds no distinguishing value to
the tag" (RFC 5646 section 2.2.4). The region in es-ES records how the bundle is
keyed; it is not a claim that the translations are Castilian, which under RFC
9110 section 8.5 is exactly what publishing it as the intended audience asserts.

It also costs reach. Under RFC 4647 a reader asking for es-MX arrives at a page
tagged es by truncating their own range, and never arrives at one tagged es-ES.
Over-specifying excludes every Spanish reader who is not Spanish.

So the rule is mechanised rather than hardcoded: the region survives only where
the bundle carries more than one variant of that language. Today's en-US and
es-ES publish as en and es; ship pt-BR alongside pt-PT and both keep their
regions, because there the region does distinguish. A blanket getLanguage()
would be right today and quietly wrong then.

The bundle's own xml:lang keys are left alone. XPath's fn:lang() prefix-matches
the other way round - fn:lang('es') matches xml:lang="es-ES" but not the reverse
- so the specific key is the reachable one. Same rule, opposite optimum, which is
why the internal key and the published claim are no longer the same string.

This restores the wire format that HEAD-accept-lang.sh has always asserted; that
test passes again untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…es no language state of its own.

The parameter this replaces was wrong for most readers, and had been since it
was written. navigator.languages arrives as a sequence, so iterating it yields
each tag - but the value was also the only declaration of $ac:langs in the
client-side compilation, which meant the browser's normalisation and the
server's lived in different files and did not match. Whichever engine drew a
page decided how its languages ranked.

This is the SaxonJS half of ac:langs(); Web-Client holds the SAXON half, which
reads the writer-supplied parameter. use-when keeps them mutually exclusive, so
the client-side compilation never sees the body that reads a parameter it does
not declare, and the server-side one never sees ixsl. Both normalise identically
- primary subtags, deduped, 'en' when the reader expressed nothing - because
there is now one place per engine that does it.

$ac:lang is gone the same way, its 36 call sites reading ac:langs()[1] directly.
It was never supplied by anything: no vocabulary term, no writer, just a derived
alias declared once per engine.

Measured in Chromium afterwards, since the shape of what ixsl:get returns is not
uniform and cannot be assumed: navigator.languages of lt-LT,lt,en-US,en gives
lt en, es-ES,es,en gives es en, de-DE,de gives de. Reaching into it with ?* -
correct for DataTransfer.types, which does arrive as an XDM array - fails here
with "Required item type is function(*); supplied value is xs:untypedAtomic".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
@namedgraph
namedgraph merged commit b1c2cb0 into develop Sep 2, 2026
1 check failed
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