Design system port: replace Bootstrap 2 with the LinkedDataHub design system - #371
Merged
Conversation
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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)ldh-header(wordmark, address bar showing the document URI, Material-glyph icon buttons, initials avatar),ldh-sidebarwithsb-*tree/classes/other sections; flyout mechanics unchangedldh-actionbar—ldh-btnCreate withldh-add-menu(design.itconstructor entries), ghost Add, pills breadcrumb (bc-pillchips + chevron separators),schedule-glyph timestamp, single design Actions menu (Edit / Access / Save as · export links · danger Delete), labeledldh-modeswitcher withmodes-popldh-blockcards withldh-block-head(badge/title/toolbar.tbactions incl. the drawer trigger); backlinks/related/parallax live in a per-blockldh-drawer; XHTML prose renders quiet (is-quiet); exactly one card layer (nested cards flatten)control-groupcontract, field skins with focus rings, modal card above the sticky chrome with a dim backdropcolors_and_type.css,app.css,core.css, m3 skin; Geist / Geist Mono / Instrument Serif / Material Symbols vendored as local woff2 — no external requestsWhat's gone
bootstrap.js+ jQuery unlinked (LDH glue rewritten vanilla infunctions.js, IXSL contract intact); WYMEditor bundle removedldh:logosprite system: all 73 icon rules and theicons/directory — Material Symbols everywherewell,span*,input-*,pull-*where inert,table-striped, …), anchored tokens renamed on both sides (span12→row-main,row-fluid→block-row,form-horizontal→ldh-prop-form); LDH's owncss/bootstrap.cssrenamed toldh.cssand prunedDeliberately kept
block,control-group,dropdown-toggle/open,progress/bar,pager/previous/next, …) — styled byldh-bridge.css, the documented transitional shim between CSR state classes and the design systembs2:mode names and thexsl/bootstrap/2.3.2/path — mechanical rename deferredKnown gaps (follow-ups)
ldh-view-toolbarFacetDropdowns; container sort/view controls not yetview-mode-segmhead/mlistmarkup.ldh-header *color blast needs the inheritance fix + self-colored panels in the design project🤖 Generated with Claude Code