Develop - #373
Draft
namedgraph wants to merge 333 commits into
Draft
Develop#373namedgraph wants to merge 333 commits into
namedgraph wants to merge 333 commits into
Conversation
…splits into label band + hovered value. Sortable th get full-strength --fg-1 text so the non-sortable headers (focus-var and button columns), which keep the base th --fg-muted, visibly recede - previously the affordance was hover-only (cursor, background, surfacing arrow) and sortable/dead columns were indistinguishable until mouseover; the hover color and [aria-sort] color rules fold away as redundant, the active column stays marked by its persistent arrow. The read-mode dl group hover no longer lights the whole group as one band: the dt spans the group's value rows (grid-row 1 / span 99, surplus implicit tracks collapse to zero height) so its own background covers the full label column like the design's row-spanning .ldh-prop-group .label, div:hover > dt paints the label band and dd:hover paints only the value under the pointer - each with the sp-6 shadow copies filling their side's card padding and the shared column gap, meeting as one continuous highlight; the background transition moves from the group div to the dt/dd that now actually change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…s one --surface-1 band with the property lists' fast background fade - the row-per-resource analog of the read-mode dt/dd hover - scoped to tbody so the sticky thead keeps its own --surface-2 sortable hover. No striping: the td border-bottom dividers already separate rows, and a resting --surface-1 stripe would spend the hover highlight's contrast. The sp-6 card-padding shadow bleed stays out - .container-results (overflow: auto) clips at the table edge, so the copies would never paint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…numeric values: number() on a text column produced NaN for every row, and a NaN sort key freezes SaxonJS's comparison entirely - later keys are never consulted, so the string key stopped applying and the DESCRIBE response's arbitrary document order leaked through unchanged in both directions (reproduced with xslt3: NaN primary + string secondary returns identical unsorted output for ascending and descending, string key alone sorts correctly). Both the primary and the default-order-by key now cast via 'if ($key castable as xs:double) then xs:double($key) else ()' - numeric columns still compare numerically (5, 9, 10, 100), non-numeric and absent values tie on () and fall through to the string key; the secondary key's number(if ... else ()) wrapper was also NaN whenever no distinct default sort existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…ugh an ldh:sort-key() function that xsl:sort turns out to honour after all. The previous commit's numeric-then-lexical key pair sniffed castability per value, which cannot express a column whose values are numeric-looking strings: schema:identifier in the Northwind data is xsd:string, so sniffing would order it 1, 2, 3 while the server's SPARQL ORDER BY - which picked the page's members by OFFSET/LIMIT over the same condition - orders it 1, 10, 11. The wrapped DESCRIBE returns an unordered graph and the client only re-sorts the page it was given, so the two orderings have to agree or paging shows a coherent set in an incoherent order. ldh:sort-datatype() resolves the single rdf:datatype shared by a column's literals once over the whole result set (xsl:sort needs mutually comparable keys, so the type is a property of the column, not of the value), and ldh:sort-key() casts the lexical key to the XSD type that orders it: xs:integer and its twelve derived types exactly rather than promoted to double, xs:decimal, float/double, dateTime/dateTimeStamp, date, time, the two ordered duration subtypes, and boolean. Datatypes XPath does not order - strings and their subtypes, xs:anyURI, binaries, gregorians, QNames, and xs:duration itself, which is only partially ordered - return (), as do untyped columns, mixed-datatype columns and values that fail to cast; those tie on the typed key and are ordered by the lexical key that follows it. NaN is filtered back out of the numeric branch because 'NaN' is a valid xs:double lexical form and would reintroduce the freeze the previous commit fixed. The lexical coalesce becomes ldh:sort-key-lexical(), retiring the four inlined copies and the comment claiming SaxonJS drops a user function's return value in xsl:sort - a NaN key freezing the comparison explains the symptom that claim was built on. The default-order-by distinctness guard moves into $default-order-by-predicates, which is empty unless distinct, so the secondary keys collapse on their own. Verified against the live Northwind data: xsd:float schema:totalPrice sorts 4.8, 7.3, 8.5, 8.64, 12.5, 13.5, 14.0, 14.4 where lexical gives 12.5, 13.5, 14.0, 4.8, 7.3; plain-literal schema:legalName sorts alphabetically in both directions; and synthetic cases cover mixed-timezone dateTimes in true temporal order, zoned dates, dayTimeDuration PT90M < PT2H < P1D, booleans, absent and dirty values, and a NaN-poisoned float column that no longer freezes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…iner takes the panel treatment - --bg-card ground, --border-default hairline, --r-lg corners and --shadow-lg lift - and the resource rendered inside it goes .ldh-block is-quiet, whose app.css rule drops the border and background. The info window borrows the modal's chrome (modal-header + modal-body) but its host is ol.Overlay's own wrapper rather than .modal, so nothing was painting the surface and the inner .ldh-block was left as the only bordered thing in an unstyled box; moving the chrome outwards and quieting the block keeps one card instead of a card nested in a panel. The container's existing overflow, already there as the scroll region, doubles as the clip that keeps the property-list hover's sp-6 bleed inside the rounded edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
….ol-overlay-container .ldh-block-head drops its side padding and keeps only the sp-3 gap under the title. The head's own inset is written for a card that wraps it in .ldh-block-body, whose ldh-bridge.css rule zeroes those sides; the default resource template emits head + property list with no body wrapper, so nothing cancelled them here and the title sat sp-6 right of the labels underneath while the head's top padding stacked on the container's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…type-family(), which reduces a literal's datatype to the type that carries its ordering and value space: the thirteen integer types collapse onto 'integer', float and double onto 'double', dateTimeStamp onto 'dateTime', and everything XPath does not order - xs:string and its subtypes, xs:anyURI, the binaries, the gregorians, QNames - onto 'string' along with untyped values, which are plain literals and so strings under RDF 1.1. ldh:sort-key() switches on the family instead of restating the URI lists, leaving the cast per branch as the only thing it still says itself. The function lands in imports/default.xsl, the property/value layer both chains already import - layout.xsl for SSR and client.xsl for CSR - so a consumer outside the client tree can reach it, and it stays free of ixsl so the SSR chain still compiles. Verified behaviour-identical across the twelve-case matrix: integers, floats, a NaN-poisoned float column, mixed-timezone dateTimes, zoned dates, numeric-looking strings, plain literals, booleans, absent and dirty values, a mixed-datatype column, resource values and dayTimeDurations all sort exactly as before in both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…eTime branch that had been written out at every site. dct:created and dct:modified carry either granularity and xs:date and xs:dateTime are not mutually comparable, so each site normalised to xs:dateTime by appending 'T00:00:00' to the lexical form - which puts the timezone before the time part on a zoned date and yields '2024-01-05ZT00:00:00', not a valid xs:dateTime. That is FORG0001 rather than a misordering: a zoned xsd:date in either property took out the whole transform, and the guards let it through because such a value is castable as xs:date. Casting through xs:date() instead keeps the timezone and cannot fail once the value is known to cast. Three sites collapse onto the function: the bs2:Timestamp sort in resource.xsl, the ldh:ListRowTimestamp sort in view.xsl together with the format-date call below it that repeated the same branch to get its xs:date back, and the action bar's schedule-icon guard in document.xsl, whose filter now reads as the exists() it always meant. The function lives beside ldh:datatype-family() in imports/default.xsl and stays free of ixsl, so the SSR chain that reaches resource.xsl and document.xsl through layout.xsl still compiles. Checked across plain and zoned dates, dateTimes with and without a timezone, and non-dates: the first four normalise as expected and feed format-date, the rest return (). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…ypes. Both enumerated only xsd:integer, decimal, double and float as chartable numbers and xsd:dateTime alone as a datetime, so a column of xsd:long - 18 of them in the Northwind data - typed as a string and its values serialised as quoted strings, charting as categories rather than a numeric axis. The same held for xsd:int, short, byte, the unsigned family, the bounded integer types and xsd:dateTimeStamp, all of which restrict a type the converters already handle and are exactly as chartable as it.
The widened list lands in both the column-type classification and the cell-value pattern in each file: they have to move together, since a column that types as 'number' while its cells still emit strings is worse than one that types as a string throughout. Each converter keeps its own copy - they carry no imports and no ldh: dependency, and stay compilable on their own. Verified by running both over the affected types: xsd:long and xsd:dateTimeStamp now yield {"type":"number"} with 42 rather than "42" and {"type":"datetime"} with Date(2024, 2, 11, 14, 47, 0, 0), a mixed long/unsignedShort column still types as a number since every member is now in the list, and output over the previously supported types is byte-identical to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf
…e the source happens to carry. The object templates - foaf, schema and dbpedia-owl in Web-Client's imports server-side, the consolidated copy in client.xsl for CSR - emit a bare <a><img></a> with nothing bounding it, so the view's table mode put a 200px headshot in one row and a photo taller than the viewport in the next, and the same markup reaches property-list dd values and the map info window. Grid mode was the only one already normalised, fitting each card's image to a 4/3 frame with object-fit: cover, and list mode renders a type icon instead. A --thumb-sm/md/lg scale in colors_and_type.css carries the caps and ldh-bridge.css spends them on the three unbounded surfaces: table.table td at 48px, the property-list dd at 160px, the block head at 240px, each picking up the card language's radius. Only max-height and max-width are set, so every source stays proportional and images already smaller than the cap are left alone - a fixed box would have to crop or letterbox, and a depiction is as often a logo, a map or a scan as it is a headshot. The map info window needs no rule of its own, since its overlay hosts a .ldh-block property list and both rules outrank the .ol-overlay-container img catch-all in ldh.css on specificity and on load order. The rdfa-editor builds tables without the .table class, so author-written content keeps its images at natural size. The block head drops width: 100% for max-width: 100% on the way. Its grid track is a fixed minmax(140px, 220px) whatever the image does, so the title alignment that rule exists to protect comes from the track rather than from the image filling it, and stretching bought nothing but an upscaled logo. The height had to be bound there regardless or a portrait source grew the header past the property list it heads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…d-A stops falling back mid-word. All three families were vendored as the Google Fonts latin subset alone, and each @font-face repeated that subset's unicode-range - U+0000-00FF plus a handful of named codepoints, nothing between U+0100 and U+017F. The files match the declaration: 225 codepoints in geist.woff2, none above U+2212. Every Lithuanian diacritic therefore missed Geist and was substituted per glyph from the next family in --font-sans, which is system-ui, so a name like Balčytis or Širinskienė rendered as Geist spliced with SF Pro at different widths and weights inside one word. The quotes in „Vardan Lietuvos" were unaffected, being in the latin subset's U+2000-206F. Each family now declares the latin and latin-ext faces as a pair the way Google Fonts serves them - same family name, disjoint unicode-range, separate file - which keeps the extended subset off the wire for pages that never reach past U+00FF. The two Geist files carry the same wght 100-900 axis as their latin siblings and Instrument Serif stays static at 400, so the weight declarations remain honest. Coverage is verified across the affected languages, Lithuanian along with Polish, Czech, Turkish, Hungarian, Latvian and Estonian: every character resolves within the family for all four faces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…e. bs2:Actions defaulted $show-edit-button to true(), so the button rendered on every card the default template mode produced - and the form it opens submits a PATCH, which AuthorizationFilter maps to ACL.Write along with PUT and DELETE. A reader therefore got an edit affordance on each block of a document they cannot write, which fills in from the server, lets them change values and fails on save. The block hover buttons in bs2:Row and the Actions-menu item in bs2:NavBarActions already tested that mode; only the header corner did not. The default becomes the same acl:mode() test, which resolves from the response Link headers server-side and from the pane's stamped acl-modes flags in the browser, so SSR and CSR gate alike. No call site passes the tunnel param true() - the view, map and object blocks all pass false() - so nothing else moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…df-document-response aligned the URL with the mode the document resolves to - ac:mode(), which returns the ?mode param if given and otherwise derives ldh:ContentMode or ac:ReadMode - and did so with history.pushState. That mode is the default the bare URL already denotes, so the alignment was writing a second entry for a state the first entry already stood for: clicking through to /constituencies/61/#this landed there and immediately moved to /constituencies/61/?mode=...ReadMode#this, and back then popped to the mode-less twin, where popstate either found a null state (cold load) or took the ldh:TabSwitch branch for the same document. Either way nothing visibly changed and a second press was needed to leave. The same push also appended after a popstate - both popstate branches call ldh:DocumentNavigate with push-state=false precisely because the entry exists - so back-navigation into an uncached document dropped the forward stack. The alignment now replaces. ldh:PushState takes $replace and picks history.replaceState over history.pushState for the same arguments, and $container defaults to the body element both alignment sites were passing literally. The push stays where it belongs: ldh:DocumentNavigate and ldh:TabSwitch, each gated by $push-state, are the only callers that add an entry. The two alignment sites - the 200/RDF path and the error path - were already setting document.title and pushing the same href from the same four values, so they collapse into ldh:SetDocumentState, which sets the title and replaces. It has no push branch, so the fetch path can no longer add an entry by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…t. Every block error path emitted div.alert.alert-block with a <strong> headline and the upstream message in a bare <pre>, and alert-block is not alert-error, so all thirteen landed on the :where(.alert) fallback in ldh-bridge.css - painting a hard failure on warning-amber ground, with the endpoint URL as the loudest text and Varnish's "Backend fetch failed" as the entire body copy. They now build from ldh:error-alert, the InlineAlert va-negative that core.css already ships: a headline naming what failed, a sentence under it that ldh:http-error-key derives from the response status instead of restating the upstream text, the URI in the body's link slot, and the raw status and message demoted into a collapsed ldh:error-detail disclosure - never the first thing read, never withheld from whoever needs it. The card stops collapsing. The bridge rule that quieted a block whose body had become a single alert is gone: the design system decided the chrome and header stay in every state, because the header carries the affordances a broken block needs most (copy IRI, edit, delete) and holding the footprint keeps the document from reflowing. app.css rings the card instead, keyed off :has(.ldh-block-error) rather than the design's is-failed class, so the ring is a function of what the handler rendered and cannot survive a later load that succeeds - nothing has to clear it. The hosts that are not a block body call ldh:error-alert directly and so cannot ring the card they sit in: the facet popover, the parallax rows, the modal's endpoint-classes region and memento's modal body; the view toolbar's result count takes a compact ldhc-tag, an alert outweighing the row it reports on. The for-each over the container and its replace-content result-document, written out at each of the ten block sites, collapse into ldh:render-block-error, retiring the unnamespaced render-container-error. Its twin ldh:render-form-error covers the modal form surface and folds in two more renderers that were still on the legacy class: ldh:add-data-form-error, which had its own div.alert and a status-by-code lookup against http-statusCodes.rdf, and ldh:generate-containers-join, whose hand-rolled <ul> of failed classes becomes one "HTTP <status> <class>" line each in the same disclosure. Copy moves to translations.rdf with es-ES alongside, closing the four TO-DO: localize markers on the add-data path and memento's three hardcoded strings, and retiring error-during-query with its last caller. ldh:http-error-key takes xs:double because SaxonJS surfaces the status as a JS number: integers promote into that, doubles do not promote back, and an xs:integer signature would have thrown XPTY0004 at runtime on every call while still compiling to SEF. Retry and the header state pill are not here. Both need a load-start hook to clear on re-render - ldh:load-block provides one for initial loads, but ldh:refresh-view and the sort, filter and parallax paths bypass it - so a pill set on failure would go stale the first time a facet change succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…f the same ixsl:set-style on the body, and lowering it was 48 more scattered through the response callbacks, hand-paired: each callback reset the cursor once per terminal branch it happened to think of. The branches it did not think of were the error ones - ldh:left-sidebar-resource-response resets inside its 200 branch and only logs a message in the other, so a response that resolved with a 4xx or 5xx left the cursor spinning until the next navigation. ldh:promise-failure covers a rejected promise, not a resolved one carrying a failed status, so nothing caught it. Raising now goes through ldh:busy-cursor(), the counterpart of the ldh:reset-cursor() that already existed for ixsl:finally, and lowering is that finally on the chain the work runs in - 47 chains carry it now against the 8 that did. A chain settles once whatever the outcome, so the reset happens exactly once per interaction and cannot miss a branch. constructor.xsl keeps its own set-style: it targets an element rather than the body, so it is not the same concern wearing the same name. ldh:ClassListLoad needed its shape changed rather than a clause added. It is two hops - list the class types, then DESCRIBE them - and ldh:class-list-response fired the second as a bare ixsl:promise element, which the outer chain does not adopt: a finally on the outer would have dropped the cursor while the describe was still in flight, which is why the resets there sat in the branches that do not start a second hop and in ldh:class-list-describe-response for the branch that does. It now returns the describe chain instead of firing it, with ixsl:resolve($context) on the branches that have nothing to await, so the caller's ixsl:then adopts it and one finally covers both hops (idiom: ldh:fetch-and-load-edited-resource). The other eleven nested promises were checked: ldh:facet-value-response fans out per-type describes fire-and-forget and already reset before they finished, so a finally on its caller is timing-identical, and the rest are either off the cursor paths or already carried their own. Only eleven of the old resets are gone - the ones in callbacks where every chain naming them now carries a finally, matched mechanically rather than by eye. Thirty-five stay: the ixsl:schedule-action paths (block delete, map feature click, the js:fetchDispatchXML upload) have no chain to hang a finally on and want converting to ixsl:promise first; ldh:RenderViewMode raises the cursor that ldh:LoadGeoResources lowers two files away; and ldh:promise-failure keeps its own reset deliberately, as 61 chains name it as on-failure and not all of them have a finally yet. Resetting an already-reset cursor is a no-op, so leaving them is harmless - it just means the sweep is partial. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…se chains, and the add-data form error takes its explanation as an argument rather than a smuggled map key. The schedule-action form has no chain to hang an ixsl:finally on, which is why block delete, the map feature click and the document delete were the paths still resetting the busy cursor by hand. Each one's onX named template - which read the response off the context item, the schedule-action calling convention - becomes an ordinary context-map function: ldh:block-delete-response, ldh:feature-description-response, ldh:delete-response. None of the three chains through ldh:handle-response, because all three report a failed status themselves rather than raising it, so the response has to reach the callback rather than divert to on-failure. Two of them take a finally and drop their hand-written resets. The document delete deliberately does not: on 204 it hands over to ldh:DocumentNavigate, which raises the cursor for its own load, and a finally on the delete chain would settle first and clear it - so that one keeps the reset in its failure branch, which is the only branch that ends there. ldh:LoadGeoResources and ldh:refresh-view also gain the finally they were missing (the cursor for the map view is raised by ldh:RenderViewMode two files away, and lowering it belongs on the chain that does the loading), which retires ldh:geo-results-response's pair of resets. Hand-written resets are down from 48 to 28. ldh:add-data-form-error's 'explanation-key' was an optional context-map entry that only four of its nine callers set, two of them via map:put purely to pass one string, and discoverable only by reading the function. It becomes a second parameter, with () meaning derive the sentence from the response status; map:contains goes with it. ldh:import-ontology-error and ldh:import-ontology-error-cleaned thread it through, the latter keeping its partial-application shape as ($context, $explanation-key, ?). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
… with a query instead, which also makes them bidirectional. The old grouping over the page RDF could only see the properties of the resources on screen, and offered predicates whose objects are never subjects themselves - on the Northwind employees view that meant a dead 'type' chip pivoting onto schema:Person, a vocabulary term with no triples in the end-user dataset. ldh:link-predicates builds the replacement as JSON XML in the idiom of its neighbours: the view's own SELECT becomes a subquery (in its own group, since a SubSelect must be the sole content of its braces) with LIMIT/OFFSET/ORDER stripped and its focus variable re-projected DISTINCT, so discovery covers the whole result set rather than the current page; around it a union asks for the predicates leaving the results and the predicates pointing at them, tagged apart by a bound boolean. Objects that are literals or that are never subjects are filtered out of the outgoing branch - a pivot onto them renders an empty view - while incoming subjects need no such check, each being by construction the subject of the triple that matched. Discovery variables carry an ac:uuid() suffix because a subquery exports its projected variables and a view projecting ?predicate would otherwise silently join with ours, and the wrapper deliberately declares no prefixes: with any, the generator abbreviates IRIs and then emits the subquery's own prologue inside the group. The chips become directional end to end. ldh:add-parallax-step takes a tunneled $inverse that swaps subject and object in both the plain BGP and its GRAPH twin, so a backward pivot re-projects onto the subjects pointing at the current results; the direction rides the chip as @data-dir into the pivot handler, is recorded with each step, and is replayed by ldh:ReplayParallaxSteps, which now walks step maps rather than bare predicate URIs. Applied steps read 'via' or 'linked by' with a matching arrow, and the row's own glyph goes neutral now that the chips carry their own. Two requests per render replace the previous one-DESCRIBE-per-predicate fan-out: the discovery query, then a single batched CONSTRUCT for labels. Labels come from the /ns ontology endpoint via the existing $object-metadata-ns-query rather than $property-metadata-query's bare DESCRIBE, since all that is consumed is ac:label and vocabulary terms are described in the ontology, not the data - the same fact the dead-end filter exploits. Knowing the full predicate list up front lets the strip render once, retiring the read-the-DOM-back-and-re-sort merge that only existed because chips used to arrive one response at a time, and the label fallback cascade moves into ldh:predicate-label. The result-set-invariant query string is cached as parallax-key so paging, sorting and mode switches re-render without re-discovering, and the guard that skipped discovery whenever no resource had an IRI-valued property is gone - result sets carrying only literals are exactly where incoming links may be the only pivots available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…as written against .ldh-block across the glasspro and m3 skins, but .is-quiet dissolves the card it elevates - no border, no background, no shadow - so a prose block translated a pixel with nothing around it to move, which reads as a wiggle rather than as elevation. Scoping each rule to :not(.is-quiet) leaves the lift where the card still exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…ent renders every rdf:type/@Rdf:resource as span.btn.btn-type, and the bridge styled it as a pill only through ul.ldh-typelist > li > span - so the resource description header got the design system's Tag em-quiet co-primary sz-sm (20px, pill radius, blue-50 on blue-700) while the identical span in a view's Type column fell through to :where(.btn), the 30px bordered fallback that stands in for un-migrated Bootstrap buttons. The declarations move onto .btn-type unchanged, ul.ldh-typelist keeps its layout, and a type now reads the same wherever it is rendered. Its dropped hover rule had never matched anything: the anchor is a grandchild of the li, so ul.ldh-typelist > li > a:hover selected nothing. Table cells align middle. With the Type column's chip 30px tall against ~20px line boxes, vertical-align: top parked every date and decimal above the chip's label - a sawtooth across each row. core.css ships no table, so the convention comes from how the system aligns elsewhere: centred wherever a single-line label sits beside a control (.ldhc-btn, .ldhc-ubtn, .ldhc-tag, .ldhc-badge, .ldhc-switch-seg), flex-start reserved for the four cases of a wrapping text block beside a fixed-height one (.ldhc-help, .ldhc-choice, .ldhc-field-foot, .ldhc-toast). A cell holding one date is the first case, as the neighbouring td img rule already assumed. The pill's own 20px height settles most of it regardless - the chip stops setting the row height, and rows tighten by about a quarter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
… block state instead of failing on a datatype. ldh:RenderChart handed every DataTable to Google Charts, which validates column types whether or not there are rows to draw - and a variable bound in no result types as a string, because SPARQLXMLResults2DataTable decides a column by asking whether all of its bindings have a given shape, and at zero bindings every one of those count comparisons is 0 = 0, so the first branch answers. A SELECT matching nothing therefore surfaced as "Data column(s) for axis #0 cannot be of type string" - a type error standing in for an absence of data, and one that no choice of category or series could clear, since the type was never read off a value in the first place. The row count is now taken from the DataTable before drawing, which keeps the guard shape-agnostic across SRX and RDF/XML results, and ldh:RenderChart is where it belongs: all eight call sites funnel through it and it is ac:draw-chart's only caller. The converter's typing is left alone. What it reports for a column with no values is arbitrary whichever branch answers, and picking the one type Google Charts accepts on a value axis would be describing the data wrongly to keep a validator quiet - 'number' for a column of supplier names. Emptiness is a fact about the result set, so the chart block is where it belongs. What an empty block renders was already specified: Components -> Block states carries a failed, an empty and a loading body, of which only the failed half had been ported. .ldh-block-blank and its three children come over with their declarations unchanged, so the chart's empty body is the same component a view's will be - the spec's example is a view, but the state is a block state and its action row is a separate optional child, which a chart with no filters to clear simply omits. No ring: an empty result is an answer rather than a fault, and nothing needs finding in a long document. The canvas yields its fixed 400px and its padding when it holds the empty state, since .ldh-block-blank brings its own inset and would otherwise sit stranded above 400px of nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…system stamps a block-state marker only in the state it names, which means whatever renders the block has to remember what it stamped and clear it when the state changes. The bridge inverts that: every resource-description header carries span.ldh-block-state.is-empty and app.css reveals it only under .ldh-block:has(.ldh-block-blank), so the marker is derived from the body the same way the failure ring above it is, and a block that goes from empty to having rows drops its marker on re-render with nothing to unset. Hence display: none where the design system has inline-flex. Only the empty variant is wired - the failed and loading variants arrive with the markup that would reveal them. The label comes from translations.rdf under the block-state-empty nodeID rather than sitting as English in the stylesheet.
… ixsl:onchange templates. onSubjectTypeChange and onSubjectValueChange were wired by addEventListener from mode="ldh:RenderRowForm", so a listener existed only on the elements that mode had walked and had to be re-attached every time ixsl:replace-content destroyed the fieldset it was attached to - which is what the re-walk at the end of the fieldset refresh is for. Saxon-JS delegates ixsl:on* modes at the document and matches the event target against the pattern, so the templates apply to any matching control whenever it appears, and a replaced fieldset needs no re-walk to stay live.
The two sequential loops in the type handler collapse into one pass over the union of the subject and object names. The pairs are disjoint - {su,sb} against {ou,ob} - so no input the pass renames can be re-matched by it, which is also what makes IXSL's deferred pending updates equivalent to the JS mutating in place and the second loop reading the result. Values are read as ixsl:get(., 'value') rather than @value throughout: the visible subject input is user-typed and the hidden old inputs are written as properties, so neither is reflected in the attribute.
The RenderRowForm template over fieldset//input stays for the autocomplete it also sets, minus its now-answered TO-DO. functions.js loses half its remaining lines; what is left there is fetchDispatchXML, which exists because ixsl:http-request cannot build multipart FormData, the OpenLayers event bridge, and the DOMContentLoaded block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
… other half becomes an ldh:CloseDropdown mode. Its typeahead loop set autocomplete="off" on inputs that already carried it - bs2:Lookup passes autocomplete=false() to Web-Client's xhtml:Input, which emits the attribute, and the RDFa editor's inputs both declare it inline and use a typeahead-input class that never matched the token the loop selected on. The one input that did lack it, the remote RDF document lookup in the add-data modal, is rendered client-side long after DOMContentLoaded has fired, so the loop could not have reached it either; it now declares the attribute like the two sibling lookups in the same file. Closing drop-downs was still load-bearing - nothing else drops .btn-group.open, since the toggle in client.xsl only flips its own group and the body handler in view.xsl dismissed facet and links popovers only. The three behaviours that were riding on the listener become applications of one mode, at the three points where a drop-down stops being current: another one opens (the toggle), a click lands outside it (the body handler, beside the two dismissals already there), or a menu pick mounts a modal (ldh:ShowModalForm, the single place any modal is appended). This narrows coverage in one respect. The listener was native and ran on every click, while Saxon-JS delivers an ixsl:on* event to exactly one template - the target, or the nearest matching ancestor - so a click on a control that has its own onclick template and is neither a drop-down nor a modal path no longer closes an open drop-down, body never having fired. The facet and links popovers this now sits beside are already governed by that same rule, so the behaviour is consistent with its neighbours rather than newly broken, but it is a difference from the JS and has not been checked in a browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…ses the gap left by moving the closer into IXSL. Saxon-JS delivers an event to exactly one template rule - the target, or the nearest ancestor matching it - so the body ixsl:onclick rule only ever sees the clicks no specific handler took, and pressing something that has its own handler left a drop-down hanging open. The new rule rides on pointerdown, which no other template claims, so the walk up from the target always reaches body. The comment says to keep it uncontested: a more specific ixsl:onpointerdown rule would shadow this one for its subtree. The containment test is what lets a toggle still close its own group. pointerdown precedes click, so dismissing unconditionally would have the toggle re-open on click what the press had just closed - the same reason the JS this replaces tested !group.contains(target) behind a setTimeout. Keyboard activation raises click with no pointerdown before it, so that case stays with the body click handler, which is why the ldh:CloseDropdown call added there is kept rather than folded in here. Measured rather than reasoned, on an isolated Saxon-JS harness driven by Playwright: a click on a span inside a handled button reaches the button's rule, so dispatch walks up; a click on that button raises no body rule, which is the limitation; an empty template terminates the walk, which is what the facet-pop rule in view.xsl relies on; and a pointerdown reached body from every target tried. Two offered refinements died there - right-click raised neither event, so the button=0 guard was dropped as guarding nothing, and Enter on a focused control raised click with no pointerdown, which is what kept the body click rule alive. Then A/B'd against the real toolbars on the dev instance, with the SEF served from disk past Varnish, which serves /static/ immutable for a week and had been handing back a stale copy that masked the difference entirely. Without the rule, pressing btn-copy-uri or the block links toggle left the Create drop-down open while presses on plain areas, on its own toggle and on another toggle dismissed it. With the rule all five dismiss, and the toggle still closes its own group rather than re-opening it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…nd the body click rule gives up a line it never used. Instrumenting the four closing paths and driving the real toolbars showed which of them still earn their place: every mouse press is taken by the pointerdown rule, which reaches the target's handler and the ones above it alike, so the toggle's close-others loop no longer fires for a press - but it is the only path that fires for a keyboard Enter on a toggle, where there is no pointerdown at all, so it stays. The ldh:CloseDropdown call in the body click rule fired in none of the six scenarios: pointer input never reaches it, and keyboard activation of a control that has its own handler stops at that handler. It goes, leaving the rule to the facet and links popovers it was written for. That the call was unreachable is a measurement over the cases tried, not a proof, so the comment says why it was dropped rather than claiming the case cannot exist. The same run caught a regression from the commit that introduced ldh:CloseDropdown. Picking an item from the Create menu opened its modal and left the drop-down standing behind it, because ldh:ShowModalForm is not the single place a modal is appended, as that commit claimed: ldh:render-add-modal-form appends its own, and so do five other sites. Enumerating the ixsl:append-content instructions whose content is a div.modal gives seven mount points, and the dismissal now runs at each, so the invariant is that mounting a modal dismisses the chrome that opened it rather than that one function does. It cannot be folded into the pointerdown rule by relaxing the containment test, which was tried and measured: dismissing on press hides the item between pointerdown and mouseup, so no click is delivered to it and the modal never opens - the pick is swallowed, which is why the JS this replaces deferred its close behind a setTimeout registered on click. The close has to follow the handler it belongs to. Verified against the running instance with the SEF served past Varnish, which caches /static/ immutable for a week and had masked an earlier A/B entirely: presses on a handled control, on a plain area, on the toggle itself, on another toggle, a keyboard Enter on another toggle, and a menu pick all dismiss, and the pick opens its modal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…at went around it stop doing so. The function already existed as the client-side override of the Web-Client stub whose server-side counterpart is the com.atomgraph.client.writer.function.UUID extension function, but its body was a call into a hand-written generateUUID, and seven of its eight potential callers skipped it to make that same call directly. Those become ac:uuid(), which is what the rest of the codebase already uses for ids, scratch URIs and authorization document names, in both the server and client pipelines. The body becomes crypto.randomUUID(), which retires the UUID.js the page loaded for that one function - the Web-Client copy that layout.xsl pulled in, and the unreferenced duplicate LDH kept beside it, which no script tag ever pointed at. It stays an ixsl:call, since XSLT has no random number generator, so this trades a script we maintain for one the browser ships rather than removing the interop. randomUUID is a secure-context API and is undefined on an insecure origin, which the comment records; LDH is served over https, and the running instance reports isSecureContext true with randomUUID present. query-transforms.xsl needed the ac prefix and its entity, having had no reason to bind them while it was calling through ixsl. Checked against the real toolbars with the SEF served past Varnish: the constructor modal, which reaches ac:uuid() on the client, renders with no page errors, and twenty-three UUID-shaped ids are present in the rendered DOM. The script tag removal itself is not exercised there - layout.xsl is baked into the image rather than bind-mounted, so the deployed page still loads UUID.js - but nothing references generateUUID after this: not LDH's stylesheets, not Web-Client's, and not the remaining functions.js. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L
…mponent, and the mode it never had stops pretending. Bootstrap 2's stylesheet is gone, so ul.nav.nav-tabs rendered as a bare bulleted list; the strip is now div.ldhc-tabs wrapping a va-line tablist of button.ldhc-tab and the results container, which carries role=tabpanel and is what both tabs point their aria-controls at. LDH's core.css was missing the whole .ldhc-tabs block - its copy stopped exactly where the design system's grows a Tabs section - so that section is appended verbatim and the two files line up again. The discriminator class loses its Bootstrap nav- prefix along the way; it appeared in three places, all in this file. Two things the old markup carried were dead. $active-mode was a literal ChartMode, so the xsl:if that would have marked View active compared against ∾ViewMode - a URI that appears nowhere else in the codebase, nor in the ac ontology - and could never have fired; it goes, and Chart is stamped active directly. The View tab is offered only for SELECT, because rendering one wraps the query into a DESCRIBE subquery and SPARQL admits only SELECT subqueries; for anything else the strip used to render a single tab with nothing to switch to, and now renders no strip at all. The results container is built once in a variable so both branches emit the same element. The activation dance that was copy-pasted into both onclick handlers becomes an ldh:ActivateTab mode applied to the clicked button, which also repoints the shared panel's aria-labelledby. It skips itself when clearing the others, and not for tidiness: ixsl:call on classList applies at once while ixsl:set-attribute is queued to the end of the transform, so clearing this tab and then re-setting it would leave the outcome to the order updates are applied in. The component's roving tabindex and arrow-key navigation are not ported - both buttons stay natural tab stops, so neither goes keyboard-unreachable, which half of that model would not give. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…do it the way the document-level Create button does. The two onclick handlers had grown a copy each of the same mechanism - mint a fragment URI, run SetResourceID over a hand-built constructed-doc, pull out the resource and its types, then drive a ten-step promise chain - so the pair collapses onto ldh:CreateBlock in client/block.xsl, which runs the chain form.xsl already had: fetch the class's SPIN constructor, instantiate it, load constructors and shapes, render the row form. ldh:render-block-row-form goes, because ldh:render-add-row-form was the same function with a different insertion point; that point is context now, stamped per flow as insert-anchor plus insert-position - the dock inserts before its own container, a block after itself. Reusing the constructor rather than hand-writing the instance is what the change is for. A chart's create form no longer carries a hand-rolled dct:title slot: title and description arrive from ldh:TitleConstructor and ldh:DescriptionConstructor the way they do everywhere else, and a view picks up ac:mode, ldh:container and ldh:showWhenEmpty besides. Each flow supplies only what binds the new block to its query - spin:query for a view, and for a chart the type, category and series read off the chart controls. The merge that folds those in knows no predicate names: the value comes from the caller and the type from the constructor's own marker bnode, so a literal inherits the datatype the constructor declared for it. A property the caller leaves blank hands over nothing and so displaces nothing, which matters because bs2:FormControl builds a literal's input from its text() node - taking the slot over with an empty value loses the control instead of pre-filling it. The chart's Create button had not worked for some time, here or on master. Its rule asked for div[contains-token(@Class, 'block')][@about][@typeof], and bs2:Row has had the typeof attribute commented out since at least the start of August, leaving @about on the block wrapper and @typeof on the inner .block-row - no single ancestor carries both, so the rule never fired and the click did nothing. Instrumenting the rule and driving the real page showed it was never entered; the predicate goes. Both buttons are shown only to an agent who can act on them. Creating POSTs a new block into the current document, so the Create buttons follow acl:Append as the other creation UI does, and the Save buttons on the query block and on the chart follow acl:Write, saving being a PATCH of the current document. Neither was gated before. The rest is placement and naming. The view's onclick handler sits in view.xsl beside the other view templates, mirroring the chart's in chart.xsl, and the two are now the same seven lines around a ldh:CreateBlock call. The tab-selection mode is ldh:SelectTab: ldh:ActivateTab was already taken by client.xsl for activating a document tab in the tab bar, where it also re-syncs that pane's acl:mode() flags, and the two have nothing to do with one another. Driven in a browser against a SELECT block: the tab strip renders both tabs, clicking View flips aria-selected and repoints the panel's aria-labelledby, the view renders, and Create yields a row form with spin:query filled in and the constructor's remaining slots editable. The chart side yields one control per predicate, carrying over whatever the chart controls held. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…'s before()/after(). The TO-DO on that line waited on SaxonJS 3 for ixsl:insert-before and ixsl:insert-after (saxonica.plan.io/issues/5543); the runtime we ship implements both, so it is discharged. Which of the two applies is stamped into the context by each flow as a lexical QName and reaches @method through an attribute value template, so the shared function inserts without branching - the dock stamps ixsl:insert-before to land the form above its own container, ldh:CreateBlock stamps ixsl:insert-after to land it below the block. The other before()/after() call sites stay as they are, because the two are not interchangeable: xsl:result-document constructs a result tree, so a node named in its content is copied, while the DOM methods move the node that already exists. Nothing here notices the difference - the row form is built by XSLT and re-acquired afterwards as the anchor's sibling rather than through the variable - but the rdfa-editor sites are the opposite case throughout. rdfae:element() is document.createElement, and the code goes on using the variable after inserting it: setting contenteditable on it, testing its parent::, injecting chrome into it, placing the caret in it. Copying would leave all of that pointed at a detached orphan. The two in block.xsl move an existing block for drag-and-drop and put it back when the PATCH fails, where a copy would duplicate it. Converting any of them means restructuring the site to build a tree and re-find the node, which is a change to behaviour rather than a substitution. Not yet exercised in a browser: whether SaxonJS honours an attribute value template on @method is a runtime question, and the deployed build predates this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…e the earlier runs had skipped. Every chart run so far had happened with no category and no series selected, which took the valueless branch and never reached the code that fills a literal slot; and no run had ever submitted the form it checked for, only looked at it. Filling a literal slot wrote the datatype after the value. A property carrying a literal holds it as a text node, so copying @* | node() and then adding rdf:datatype appends an attribute after children, which is an error - the alert named ldh:categoryVarName as the start tag it could not write to. Attributes now go first and the value follows. Saving the created block sent IXSL:INSERT-AFTER as the HTTP method. ldh:render-add-row-form already had a $method - the form's own method, post - and naming the insertion method $method too shadowed it, so bs2:RowForm was handed the serialization method and put it on the form. It is $insert-method now, with a comment saying why it cannot share the name. The same function shadows query-string and request-uri three times each for its three metadata fetches; that is pre-existing and left alone, but it is the same hazard. Driven end to end with a category and four series selected: the row form comes back with categoryVarName and every seriesVarName carrying its value and lt=string, chartType and spin:query filled from the caller, title and description empty from the constructor; saving it posts, succeeds, and the block re-renders through ldh:render-chart to chart-results-response, with no alert at either step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Tgf5DjbYiX3SPnCFSgCzd
…hat is. The block-reorder dragover rule matches every element inside a block row and carries priority="1", and it guarded its payload with an xsl:if that fell off the end for anything else - so a file drag reached it, nothing called preventDefault(), and the browser refused the drop. That is the whole content area of any container, since a container's graph carries rdf:_1 <#select-children> and every element in the blocks below it is inside a block row. Measured on the running instance: everything inside .ldh-block-row refused, everything outside accepted, which is why the bare pane looked like it worked. What hid it was the comment claiming the file rule had higher import precedence than block.xsl. client.xsl *includes* block.xsl, and xsl:include confers no precedence, so the two sit at the same level and priority decided the other way. The fallthrough was built in one direction only: client.xsl yielded to block drags, block.xsl never yielded back. Now every drag rule yields on a payload it does not own, which is what the payload marker was for. Three gestures, disjoint by type: Files, ...linkeddatahub.block, ...rdfa-editor.block. The drop itself moves off content elements onto one fixed overlay, raised on the first dragenter carrying files. Being topmost it takes every dragover, dragleave and drop that follows, so the enter/leave bookkeeping stays on a single element - no counter needed to survive crossing a child boundary - and no content rule sees a file drag at all. It is also the missing half of the feature: nothing on screen ever said a drop was possible, so a page that accepted one and a page that did not looked identical under the cursor. The overlay joins Menu, Popover and Modal in the core kit as the page-scale sibling of .ac-file-drop and .ldh-dropzone. The ReadMode condition goes with it. Dropping RDF POSTs triples into the document's graph, which is orthogonal to the layout rendering it; that test was avoiding the block DnD collision the payload marker now handles. Where the mode does belong is afterwards - a successful import lands in ReadMode, because reloading a ContentMode document into ContentMode redraws the same blocks and an import that worked looks like it did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EptZ6w7rd883q8N4CMorR
It shipped in fc479e8 unexecuted - the stack it targets was not running while the responsive work was verified, so every assertion had been checked by hand against a different instance instead. Its first CI run failed five of seven tests, all of them the spec's fault. Nothing in that run indicted the stylesheet: the two assertions that did exercise it, the header's address track and the tab strip's scrollport, passed at both 390 and 768. Three defects, and they rhyme - each one asserted about a page without checking what that page renders. The statement grid was measured on /ui-fixtures/item-01/, which renders no blocks and no property list at all; the fixture container renders several. calibration.spec.mjs prints the anatomy of both, so the run that failed also carried the answer. The overflow test visited two documents on one page. The second navigation aborts the first page's in-flight SPARQL, Saxon-JS raises that as an alert, and lib/console.mjs fails the test in teardown - after the assertion itself passed. Each document now gets its own test, and so its own page. Being a race, it had failed intermittently, which is the worse half: the tablet case passed on its third attempt. The chart assertion called test.skip() when .chart-controls was missing, so it went green while testing nothing - and what it was not testing was repeat(auto-fit, ...), half the responsive change. The controls were missing for a structural reason rather than a timing one: ldh:ResultSetChart is data until something puts it in the document's rdf:_N list, and only ldh:Object and ldh:XHTML may be values there, so a chart reaches the page wrapped in an Object exactly as the built-in children view is. The fixture created the chart and never made it content, so the element could not appear however long the spec waited. Seeded now, and the skip is an auto-retrying expect(): a missing component fails the build instead of quietly shrinking it. The grid assertions also stopped pinning querySelector(). The container holds several statement grids and document order says nothing about how wide any of them is - a grid inside a narrow block is meant to be collapsed at a desktop viewport. Desktop asserts that at least one is still three columns on the original tracks; the phone asserts the widest has collapsed. Verified by running it: 12 owner tests pass, none skipped, against a stack built from this working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
Three defects that scripting the screencast scenes turned up against the northwind demo, recorded there as measurements and left unfixed. A facet on either of the two biggest containers never returned. The value query appends a label lookup for the values it found, and that lookup walked an eight-alternative property path under GRAPH ?labelGraph. Jena evaluates a path whose graph is a variable as a generic path operator over every graph in the store - 36,877 label rows here - and then joins them against the left side. On /orders/ that is 24.9s, with no error and no failed request: the browser waits. Binding the predicate with VALUES instead leaves a plain triple pattern that uses the quad indexes, and the same lookup takes 0.055s for the same results. Both of the obvious fixes are worth recording as tried and wrong: FILTER(!isLiteral(?value)) inside the OPTIONAL still takes 26.1s, because the filter cannot be reached before the path it guards has been evaluated, and moving the aggregation into a sub-SELECT so the left side is 89 rows still takes 2.7s. The cost is the path evaluation, not the join size and not the literal-subject case that made the lookup pointless in the first place. Timed on the query the browser actually sends, by running ldh:bgp-value-counts itself under xslt3-he and handing the result back to SPARQLBuilder. In the page the five /orders/ facets answer in 131-248ms and /customers/ in 131, and a URI-valued facet still shows its labels. The x on a parallax step chip did nothing and raised an error. The chip carries facet-pill and contains a span.x, so it matched the facet pill's inline clear - which binds the div.facet its pill sits in as="element()", and a step chip lives in span.parallax-steps, with no facet ancestor. Removing a step is now a mode, ldh:RemoveParallaxStep, that the chip body's click and a higher-priority rule on the chip's x both apply: one path rather than two copies that can drift. Either one takes /products/ from the 8 rows of a Category pivot back to 77. A chart axis bound to a URI-valued variable printed the cell's escaped markup, one <a href="..."> per bar. The link is there because Saxon-JS cannot intercept Google Charts events, so a Table cell has to carry a real anchor - but allowHtml is a Table option, and every other chart type prints the cell as text, on the category axis, in the legend, in a tooltip. Only the Table gets the anchor now; the rest get the object's label, and the chart type reaches the data table as an argument. Two more URIs printed where a label belongs in the same render: the column label was the property URI, which is what the legend and the Table's own header show, and the axis titles were the raw $category and $series, which name columns rather than saying anything to a reader. The column label is ac:property-label() and the axis titles are read back off the data table's columns, so both paths - properties and variables - are titled by whatever that table already resolved. The labels for that axis have to come from somewhere. ldh:RenderViewResults leaves its object-metadata on the block cache beside the results, because the chart controls rebuild the data table from the cache on every change; without it a category axis degrades to the fragment of each URI and reads "this" ten times. Verified in the browser: Meat/Poultry, Seafood, Beverages against a Category axis and a Price legend, and the Table chart keeps its links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
Asking the endpoint for a document rather than for results - Accept: text/html, what a browser sends - was answered 500, and had been since the document-tabs work landed in April. The shell binds its content pane as="element()" and fills it by applying ldh:TabPanel, which the tabs refactor introduced matching rdf:RDF only, as it did the ldh:DocumentBody beneath it. Before that refactor the body applied ldh:ContentBody directly, and that mode has always had an srx:sparql rule rendering the results table - so a result set went from rendering to matching no rule at all, and an empty sequence where an element is declared is not an unstyled page, it is XTTE0570 and a 500. Both matches widen to rdf:RDF | srx:sparql, the way every sibling in that file is already written, and ldh:DocumentMetadata gains the empty srx:sparql rule that stops the built-in template copying the bindings' text into the page - the counterpart of Web-Client's own empty srx:sparql rule for ac:Aside. Nothing else in either template needed touching: ac:ActionBar and its three zones, ac:MediaTypeList, the breadcrumbs and the footer all match result sets already. Fixing the server exposed the second half, which no response-reading test can see: the client painted an error block over the table about a second after it arrived. Saxon-JS re-fetches the document once it hydrates, and ldh:snapshot-params kept only ?version and ?timemap from the URL - so the re-fetch asked the endpoint for nothing, was told 400 "Query string not provided", and rendered that http:Response where the results had been. The function's comment already called these representation-selecting params, which is exactly what the SPARQL protocol's three are for an endpoint document, so it takes the name ldh:representation-params and carries query, default-graph-uri and named-graph-uri alongside the Memento roles. Keeping the query is not sufficient on its own: the request asked for application/rdf+xml unconditionally and a SELECT is answered 406, so a request carrying ?query= now accepts both shapes and the response's own media type decides how it renders - measured against the live endpoint, SELECT returns application/sparql-results+xml and CONSTRUCT application/rdf+xml, both 200. The success branch accepts either media type and dispatches on the document element rather than on /rdf:RDF; everything inside it guards itself, because a result set has no diff, no object metadata, no blocks and no canvas. The reason this shipped through 5.9.x with a green suite is in the coverage: all fifteen tracked tests under sparql-protocol/ ask for application/sparql-results+xml or application/n-triples. Not one asked for a representation that reaches the XSLT writer. GET-sparql-xhtml.sh does, asserting the pane, a header cell per projected variable and a row per solution against a query that binds its own values, so it holds on any dataset. The Playwright spec covers what it cannot: that the table is still there after the client's re-fetch, and that coming back to the page client-side rebuilds it with no server-rendered body to fall back on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
app.css (PAGED VIEW) caps a paged view's body at min(70vh, 620px), makes it a flex column, and hands the scroll box to its direct .ldh-grid-block / .ldh-list-block child. LDH never has that child: ldh:GridViewBlock and ldh:ListViewBlock both render one level down, inside .container-results, so those child rules have never matched here at all. The cap was survivable only because the wrapper is itself a scroll container - max-height: 80em, overflow: auto - and shrinks under it, which is exactly the arrangement the kit's own comment anticipates and names. Grid mode takes that scrollport away. The m3 card draws its border as an outside box-shadow and the grid sits flush in the wrapper, so any clip box cuts the card edges, and the rule above it opts out with max-height: none; overflow: visible. The cap never came off with it, and a flex item with overflow visible has a content-sized automatic minimum: it will not shrink and it does not scroll. There is no scrollbar and no clipping to notice - the rows simply leave the block. Measured on /territories/ at 1920x1080 before this: an 890px grid in a 620px body, 358px of cards outside it. What that looks like is a paint-order consequence rather than a second bug. .ldh-block-row is position: relative, so every content row paints after all in-flow content in tree order: the escaped cards went over the page footer, which is static, leaving its text visible only through the grid's gaps, while the next row's own background and text painted over the cards. That is how the report came in - a paragraph of prose sitting inside a card, with an editor block apparently drawn on top of a view. Uncapping the body when the results region holds a grid is what the opt-out already assumes: the page scrolls the grid, and the pager sits after it in flow. List and table are untouched, their wrapper still being a scrollport - verified, not assumed: the same probe reports a 358px spill in grid mode and -87px in both of the others before the change, and -87px in all three after, with the footer moving from 1052 to 1497. ui-tests/specs/view-overflow.spec.mjs guards it across the three modes that page. It addresses the body exactly as the kit rule does, so it needs no fixture-specific locator, and it asserts geometry rather than declarations: a max-height is one way to satisfy the invariant and not the only one. The results region ends inside the body that frames it; a region that does not scroll contains its own rows, read off the computed overflow rather than hardcoded per mode, since list and table legitimately let rows run past a scrollport; and the footer starts below the results, which is the half a fix that contained the rows but left the page's own height wrong would miss. List and table are the controls that say the cap itself is not what broke. Run against the pre-fix stylesheet the grid case fails at 565px outside a 620px body while both controls stay green; after, 6/6 pass and the full suite is 56 passed, 44 skipped, 0 failed. Nothing else moves. Web-Client has no .container-results, .ldh-block-body or .ldh-pager - the wrapper is this repo's, emitted by client/block/view.xsl - so there is no second copy to keep in step. The fix is in ldh.css, which is not part of the vendored kit, so the design system has nothing to take from this; its five stylesheets that changed since the hosted project's last recorded update were audited and are in sync, app.css and surfaces.css by full byte diff, the other three against the specific rules their pending commits added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
view-overflow ran in both projects and failed nine times in CI - three modes, each retried twice - waiting 20s apiece for a paged view that was never going to appear. The anonymous context got the 403 error page, whose anatomy shares no selector with the block being measured. The obvious reading, and the one every sibling spec's skip already states, is that the fixture container is owner-owned. It is not. Nothing in a fresh dataspace is readable without a certificate: the anonymous probe of the ROOT is a 403 too, before any fixture exists, because the shipped Public access authorization in platform/datasets/admin.trig carries acl:mode and acl:agentClass but no acl:accessTo or acl:accessToClass - it grants nothing until something fills it in. The suite never does. http-tests does, via admin/acl/make-public.sh, which is why this passes on a dev stack that has run the HTTP suite and fails on CI's virgin one. The workflow even waits for anonymous 403 at the root as its readiness signal. So the reason is not ownership and not the fixture. Run against a public instance the spec passes in both projects, which is the proof it was never owner-specific: what it asserts is that a paged view's rows stay inside the block that frames them, and that geometry is the same whoever is reading. The authorization axis would measure it twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other input this suite depends on is seeded: 25 items, the query, the view, the chart, the taxonomy package. Authorization was the one it took from whatever state the instance happened to be in - which is why view-overflow reproduced in CI and not locally, and why nobody noticed for as long as the anonymous project asserted nothing. Of its 50 tests, 47 skipped and the three that ran were calibration probes, which assert nothing and passed against a 403 error page. Four pieces, in the order they depend on each other. A control document, ui-fixtures-private, that no authorization ever targets. Its anonymous 403 is what proves no blanket grant is in force. It is seeded because the check cannot be made against an assumption, and it is a SIBLING of the fixture container rather than a child: document-tree counts that container's children against itemCount, and a 26th child fails it - which it duly did. The baseline check runs after seeding, because LDH answers 403 for a document that does not exist as readily as for one that is refused, so probing before there is anything to probe proves nothing. Verified both ways: it passes on a virgin instance, and with a scoped grant constructed on the control it refuses to start the run. The grants themselves, scoped the way http-tests writes them - ldh admin create authorization, one document at a time, slugged so teardown can delete them, never the global make-public, which is an INSERT into the shipped authorization with no reverse in the CLI. What they had to contain was measured, not guessed: a document-scoped grant alone does NOT produce a working anonymous page. It yields four 403s and a Saxon-JS alert, because the chrome walks the ancestors and queries the endpoint. Adding the ancestors cleared most of it; the last 403 was a bare POST /sparql, which is what make-public's own #sparql-post authorization exists for. Hence two authorizations, kept apart so each scope is legible: the documents, and the endpoint at read and append. The endpoint grant is the floor, and it is worth stating plainly: /sparql enforces no per-graph ACL. With it in place an anonymous SELECT reads every graph in the dataspace, including documents whose HTTP representation is 403 - confirmed against a sentinel. That is the platform's behaviour and make-public grants the same thing, but it means the control proves only that no blanket DOCUMENT grant is in force, and the comment says so rather than claiming more. A cache purge after seeding. The negative control - delete the grant, expect the spec to fail - passed instead, because Varnish was still serving the HTML variant it had cached while the grant existed, and the suite reuses identical URLs every run. http-tests bans the whole cache before every test; seeding happens once here, so once is enough. Best-effort, so a dev pointed at a stack they do not run locally still gets a suite. And the spec that justifies all of it. The claim is not that an anonymous agent is refused - that is the default, and denial says nothing about rendering - but that one who IS allowed to read still gets none of the controls that write. Selectors measured by diffing both contexts against the same granted document, and asserted as presence rather than visibility, since every owner affordance is a menu item in a closed dropdown. The owner half of it reads the control document, which is the half the preflight cannot perform: it holds no certificate, so it cannot tell a locked document from a missing one. The anonymous project goes from nothing asserted to two tests. The 21 further skips that exist only because nothing could be granted before are left for their own commits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening any document on an instance nobody had made public raised a modal alert carrying raw Saxon text - "Required cardinality of first argument of ac:document-uri() is exactly one; supplied value is empty". On the root document too, so it was the first thing an unauthorized visitor ever saw. ldh:TreeChildrenPlaceholder appends an li.tree-loading carrying no anchor at all, to be replaced when the children response lands. When that response is not a 200, ldh:tree-children-response emits a message and nothing else: it neither replaces the placeholder nor stops the chain, and ldh:tree-children-continue invokes the continuation regardless. ldh:doctree-descend-after-load then ran its predicate across a list whose only row had no @href, and ac:document-uri() of an empty sequence is a cardinality error against a param declared as item(). It escaped to ldh:promise-failure, which alerts whatever is not an ldh:HTTPError. A step predicate is the whole fix: a row with no anchor is not a child to descend into. Both sites take it, the one that raised and the one two branches up that is shielded only by its xsl:when. ldh:tree-node-uri was made empty-tolerant for exactly this and says so in its comment - "the rows this is asked about include the loading placeholder, which carries no anchor at all ... which a required cardinality turned into a runtime error mid-walk" - but substituting it here would be worse than the bug: it returns empty, and starts-with($uri, '') is true, so the placeholder would be SELECTED as the next node and fail a frame later on the same absent @href. Not an authorization bug, though that is how it was found. Reverting the fix fails the new spec for the owner as readily as for the anonymous reader: what matters is that the children query failed, not who asked it. Anonymity is merely the reliable way to be refused. Left alone deliberately: the placeholder is still never replaced on failure, so a refused children query spins "Loading..." forever. That is a UI concern and its own change, and the spec asserts nothing about the placeholder so fixing it will not break this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sparql-results and document-tree skipped the anonymous project for reasons that had stopped being true. "The endpoint grants acl:Read to authenticated agents; an anonymous request is a 401 and says nothing about rendering" - it is a 403, and the refusal is not the endpoint's own ACL but the dataspace granting nothing to anyone without a certificate. "The fixture container is owner-owned; these specs are not about authorization" - the second half is right and the first is not, since ownership is not what hides it. Both were true enough when the only anonymous state available was "refused everything". Now that the suite states the access it needs, neither spec has anything to skip for: they read a document and assert what is rendered, which is a claim about rendering in both projects. Four more anonymous tests, and nothing in either spec changed but the removal. The anonymous project now passes thirteen tests. It passed none before this week, which is what let a spec that forgot to skip fail in CI and nowhere else. concept-tree and concept-hierarchy still skip, and their reasons are sound as written - the taxonomy fixtures are granted to nobody. Reconsidered when they are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…written down. "The taxonomy fixtures are owner-owned" was not true of anything. Nothing in a dataspace is readable without a certificate until an authorization says so, and a taxonomy document is a dh:Item created by ldh create item, indistinguishable from the fixture items the suite now grants. Granting these ten documents would make all fifteen tests run. The reason not to is the division of labour, which nothing had stated. WHETHER a refusal happens belongs to http-tests, where admin/acl/ covers the modes, the classes, the groups, the owner, and make-public against the real HTTP API; asserting it again through a browser is the same claim in a runner an order of magnitude slower. WHAT a refusal does to the client belongs here, because it is invisible to a suite that reads responses - a 403 can be correct at the HTTP layer while Saxon-JS raises an alert over it, which is precisely the defect fixed two commits ago, and tree-children-failure asserts it directly by injecting the refusal in both projects. Between those two, the concept tree renders from the hierarchy queries alone and the hierarchy blocks render what the query returns, identically for whoever may run it. A granted taxonomy would buy fifteen re-measurements of the same markup. The specs that DO differ by agent already run in both projects: affordances in anonymous-affordances, refusal in tree-children-failure. Comment and reason strings only; both specs still pass fifteen and skip fifteen, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… not the one under the pointer. Dropping a block resolved the target document with ldh:base-uri(.), where the context item is whatever element the pointer was released over. A block that embeds another resource stamps @data-base-uri on its injected subtree, so that walk returned the EMBEDDED document whenever the release landed inside one - and on a dashboard that is most of the drop area: the northwind root carries eight such elements across five distinct bases, one of them the LDH ontology itself. The move then rewrote rdf:_N on that resource and addressed the PATCH to it, which for an external URI means going out through the Linked Data proxy (?uri=). Measured: PATCH /?uri=https%3A%2F%2Fw3id.org%2Fatomgraph%2Flinkeddatahub with the ontology as ?doc in the VALUES row, answered 502, the optimistic move reverted, and the reader got "Could not move block". Reordering was broken on any document containing an embedded block, which is what a content document usually is. $block is already constrained to a child of .content-body, so it carries no such stamp and names the document whose sequence is being reordered. Resolving from it is also what the surrounding code means - $document-body and $source-block are both resolved from $block for the same reason. Only a real drag reaches it. Dispatching the events on a chosen element lands outside the embedded subtree, so the synthetic regression check passed against the broken code; this was found by driving the drag through Chromium's own drag-and-drop processing model instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EptZ6w7rd883q8N4CMorR
…pixel size. Nothing constrained an image inside an ldh:XHTML block, so one published at twice the column width — as documentation stills are, to stay sharp on a retina display — rendered 2240px wide inside a 1184px column. The content body is overflow-x: auto, so the reader saw the picture's top-left corner and had to scroll sideways for the rest. The static docs build hid it, because docs.css carries the same rule and the live app never loads it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dited. The editable region renders inside the XHTML body's .main, so the content sizing rule already matched it - and tied the property-value thumbnail cap on specificity, losing on source order. Prose images came out as 160px thumbnails in the editor and full column width in the read view. One declaration now covers both surfaces; the editor branch spells out .rdfa-editor-content only to win that tie, and max-height: none releases the cap. A genuine depiction-style property value still thumbnails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tead of on top of it, and is offered only where it belongs. Three defects on one flow, all measured on the running instance rather than read off the code. The block the create was launched from disappeared. ldh:CreateBlock anchored the row form on the clicked card, so the form landed inside that block's own .ldh-block-row - and ldh:form-horizontal-submit-success renders a whole row and swaps out the row the submitted block sits in, which was therefore the query's. Measured in Properties on a document holding one query block: rows went from [#test-query] to [#id2e18266e...], the query gone from the page until a reload, the RDF untouched all along. The anchor is now the block's row at the document's content level, so the new card is a sibling of the rows rather than a child of one, and the save's row-swap replaces the card it actually created. The Create buttons now require the Properties layout. What the flow makes is a resource of the document, which is what Properties renders; the content layout renders the rdf:_N sequence, whose members are ldh:Object and ldh:XHTML blocks, so a chart reached from content is reached through an ldh:Object pointing at it - the rule the document's own Create menu already follows. In content the flow had been writing a direct member anyway, by accident: the sequence triple comes from the POST-form handler in client/block.xsl, which matched through the ldh:Object wrapper the query block rides in and counted the new card's preceding siblings inside that wrapper's value area, so the index it produced was the wrapper's own. Measured: rdf:_2 carrying both the new chart and #test-query-block. In Properties nothing writes the triple, and the saved chart is simply a resource of the document with its spin:query - which is what the create form has always prefilled, for views as well as charts, contrary to the report that started this. A saved chart could open in an error state. The chart pane offered every result column as a series, the category included, so picking any plotted type raised "All series on a given axis must be of the same data type" - and a chart created from that pane carried the selection into its ldh:seriesVarName and kept raising it after a reload. The default now takes the number columns minus the category, and the category is the first column that is not a number. Which columns those are is asked of the converters that build the data table (ac:DataTable for a SPARQL variable, ac:DataTableColumns for an RDF property) rather than decided again from a second copy of the xsd datatype list, and the default itself, which query.xsl and chart.xsl had held as two identical copies, is now one pair of functions in the chart layer. The document layout reaches the client as data: ldh:TabPanel already received the mode and dropped it, and now stamps data-mode on the pane beside data-base, data-endpoint and data-acl-modes, with the reused-pane branch re-stamping it exactly where it already re-stamps data-acl-modes. onSPARQLResultsLoad takes the value as a parameter rather than resolving it - ldh:RunQuery defaults it off the pane the form is in, and the tab handlers, which are entry points, read it from the node in hand. It is named document-mode because query.xsl already has a mode parameter for the block's own ac:mode, which is the layout of its results: two scopes, two names. Verified with Playwright against the live instance: 12/12 - the pane carrying data-mode on a server-rendered load and re-stamping it when the layout switches inside one pane, no Create button on either tab in content, both present in Properties, the query block surviving the save and the reload with the new block added beside it, the pane's default series excluding the category, and no error banner on the pane, after the save or after a reload. The saved chart reads chartType ac:BarChart, categoryVarName "categoryName", seriesVarName "productCount", and the document's sequence is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ead of being fetched from the window.
First slice of the sweep away from window-scoped accessors inside callbacks and named templates: acl:mode() reads LinkedDataHub.acl-modes off the window, and four declarations called it to decide whether to draw a button. They now take the modes they gate on.
ldh:render-chart and ldh:render-query read $context('acl-modes'), which the chart's and the query's ldh:RenderRow put there; onSPARQLResultsLoad takes $acl-modes as a parameter, forwarded by ldh:RunQuery beside the document mode it already forwards; and ldh:view-create-insert reads $context('acl-modes'), threaded through ldh:RenderViewResults and ldh:ViewContainer. In that last one the value is named document-acl-modes, since the function already holds the container's modes and the two tests are deliberately separate - a forward view PATCHes its linking triple into the current document and needs acl:Write on both.
The value enters at the boundary, from the pane rather than the window: (ancestor::div[.ldh-pane], ldh:active-pane())[1]/@data-acl-modes. The pane already carries the modes as data-acl-modes for exactly this, which is also per-pane correct where the window accessor was not - with several dataspace tabs open it answered for whichever document was active. The active-pane fallback covers a block with no pane ancestor, which is what a modal-hosted one is, and gives it the answer the window accessor used to give. The same fallback goes onto the document-mode reads added in 3752246, which had the same gap.
One acl:mode() call remains in the client layer, in the match pattern of an ixsl:onclick rule, where a pattern can take no parameter and the handler is an entry point anyway.
Verified with Playwright against the live instance, 7/7, with an anonymous pass as the control that the value is really flowing: signed in with the owner certificate the query block draws its Save button, the chart block draws its own and the query block offers Create in Properties, with a clean console; loading the same document with no client certificate, all three are absent. The modal-hosted path is not among the measurements - the drawer's hover-open would not drive reliably - so the active-pane fallback is reasoned rather than observed; it can only restore the answer the window accessor gave, never restrict it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view stops asking the same question seven times. Second slice of the sweep away from window-scoped accessors inside callbacks and named templates. sd:endpoint() reads the active pane's data-endpoint off the page; seven declarations called it to answer a question their caller already knew the answer to. ldh:ViewModeChoice took an $endpoint and compared it against sd:endpoint() once per view mode - seven copies of if (not($endpoint = sd:endpoint())) then $endpoint else (), one per renderer, which is how a family of emitters drifts. It now takes $local-endpoint and decides once: $remote-endpoint is the view's endpoint when the results come from somewhere else and empty when they come from this pane, and the seven renderers pass that. ldh:RenderViewMode carries the value through, and it enters at ldh:RenderViewResults off the pane its $container is in, beside the access modes that arrived there in 94f6e02. onSPARQLResultsLoad takes the same value from ldh:RunQuery, which reads it from the pane the form is in. ldh:block-object-value-response reads $context('endpoint') for the object-metadata query, put there by the object block's ldh:RenderRow. ldh:ClassInstancesLoad was calling sd:endpoint() while already holding an $endpoint parameter, so it now caches the one it was given. ldh:SearchLoadDeferred takes the endpoint its caller resolves, which is the modal-search keyup handler - an entry point, where reading the page is the point. Eight other calls are left exactly where they are: they sit in ixsl:on* handlers that already pass the value into a context map or a with-param, which is the shape this sweep is moving everything else towards. Four targets are not in this commit because they do not terminate at an entry point: ldh:NavigationUpdate is called by ldh:RenderTab and ldh:TabSwitch, ldh:RDFDocumentLoad by ldh:DocumentNavigate and its fourteen call sites, ldh:TreeChildrenFetch by ldh:doctree-descend, and the two form-violation callbacks by every submit handler that feeds ldh:row-form-response. Threading those reshapes document navigation and every form submit, and the violation callbacks cannot be exercised without provoking a constraint violation in each flow - work that deserves its own pass rather than the tail of this one. Verified with Playwright against the live instance, 12/12: on a document carrying views, object blocks and charts, the view renders 16 results and re-renders in Table, List and Grid through the mode switcher, three object blocks render with ten POSTs to the endpoint from the context, both charts draw, and the console stays clean; the search modal, opened by its keyboard shortcut, renders results and re-renders them on the deferred keyup path. Class instances is not among the measurements - it is reached through the drawer, whose hover-open would not drive reliably - and the no-pane-ancestor fallback is still unmeasured, since the search modal turns out to render inside the pane rather than outside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s left when there is nowhere to. The client had five ways of not reporting a failed request. Four branches only logged: the document tree's children (its "Loading..." row spun for the rest of the session), the breadcrumbs and both class list queries. Others passed the context on without what they had failed to fetch, so a form was built without its constraints, shapes or constructors, ontology views silently did not appear, a ?diff= whose compared version could not be read rendered as if nothing had changed, and a view's container probe could not be told from having no container. The 3D graph and the schema endpoint lookup read response bodies without checking the status. Saving a query or a chart and switching a query to its results had no failure handler at all. And nineteen branches did report, through alert(), as did ldh:promise-failure for every error that was not an ldh:HTTPError - which is how a cardinality error in the tree descent reached anonymous visitors as raw Saxon text. ldh:promise-failure now takes the element the chain was working for and a headline: on-failure="ldh:promise-failure($host, 'form-not-loaded', ?)", at all 61 promises that used the one-argument form. A step with no element of its own raises the failed response with ldh:response-error; fn:error's third argument survives into the failure map as the JS error's errorObject (measured in Chromium against SaxonJS 3), so the handler explains the status - "You do not have access to this." - rather than restating the upstream text. A timed-out or unanswered request (SXJS0008, SXJS0009) reads as a request that did not complete; an aborted one was superseded by another navigation and reports nothing. ldh:RenderFailure decides the shape by what the host is: a list gets an error row in place of its rows, a form an alert in its fieldset, a one-line host (the breadcrumbs, the result count, the Create slot) a Tag, and anything else an alert with its technical detail inserted first, replacing the host's previous failure so a retried action reports once. alert() remains in ldh:render-failure for one case: a host that has left the page by the time its request fails, such as a dialog closed mid-flight. No host at all reports nothing. The breadcrumbs used to render their failure inline, until 9784408 turned it into a message. Lookups that only label something degrade instead of failing, like the label lookups before them: the class descriptions, an Object block's /sparql metadata (whose failure used to raise alert() and render nothing) and a combobox's target document. A package registry that cannot be reached leaves the app settings editable, with the alert where the package list would be. The Create slot reports only on views that have a class to create. ldh:restore-failed wrote with method="ixsl:prepend-content", which SaxonJS 3 does not have, so a failed restore raised instead of reporting; it goes through ldh:render-failure now. The taxonomy package's three promises take hosts like the platform's (its LinkedDataHub-Apps copy changes in lockstep). Verified with the working tree's client served in place of the stack's, every write refused by the browser: the tree and class list on a dataspace that refuses anonymous readers, a refused breadcrumb parent, a view re-query with no response, an edit form whose constraints are refused (and a retry replacing its alert), and a failed navigation reported above the panes and cleared by the next one - no dialogs, no console errors. The rest compiles, including the package composed with the platform. tree-children-failure now asserts the error row and no loading row; it has not been run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ning a browser tab. The info panel a node click renders carries the node's URI as a link, and that link is the graph's only way out of the canvas. target="_blank" sent it to a new browser tab, which made the graph a dead end: a walk through it could show where a resource was but not go there. Every other link in the app navigates in place, and cross-dataspace moves have the app's own tab strip. Without the attribute the link also matches the client's navigation handler, a[not(@target)] in client.xsl, so it loads as a document navigation - an external node URI through the Linked Data proxy, like any other link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lse. Package imports used to be composed as the last xsl:import on both render paths, above every core module, so one rule matching rdf:RDF in ac:Head replaced the page. Both composers now insert them at a marker - right after hooks.xsl, the new module that declares the open modes and holds their generic fallbacks (tree node, property list, the client's row hook) - and below LDH's sealed tree. Import precedence beats priority, so a package can specialise an open mode and cannot contradict a sealed rule, a typed rule or a global; ac:Head is re-declared in LDH's layout to seal it, since Web-Client sits below the packages. ldh:RenderRow no longer carries the extension point: it is a pure walk that asks the new leaf mode ldh:RowHook at every element, so a hook rule can add work for a node and can never take its subtree out of the walk. The platform's own view injection and the typed-block rules move to that mode. The server composer follows the thin entry wrapper to the module that carries the marker and serves the composed module through a dedicated compiler's resolver; the client service sends a composed client.xsl as the compile entry instead of a wrapper that imported it. Stylesheets without a marker keep the old behaviour and log it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…x box on white. The iframe carried width="560" height="315" and sized itself from them, so inside a block body two columns wider it sat at the left edge with the rest of the card white beside it. The box is CSS's now: 16/9 is what an /embed/ URL serves, and a portrait source letterboxes inside the player, where the ratio it wants is the player's business and not the card's. The well is --surface-2 + --border-default + --r-md, which is .ldh-nblock's vocabulary, so this recession and block-in-block nesting read as the same system; the radius steps down one stop for the inset player, following that section's depth ramp. A player paints its own letterbox black, and black straight against the card's --surface-0 reads as a hole punched in the surface. frameborder goes with the sizing attributes - deprecated since HTML4, and border: 0 already says it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…at failed. 937a215 made every failed request report where it was made, and in the dataspace drawer that was the wrong answer. A dataspace that refuses the reader's queries - an anonymous visitor on one nobody made public - filled the Document tree and Classes sections with an alert each about access. The reader can do nothing with a tree that cannot open or a class list that has no classes, and a red box explaining why is chrome shouting about itself. Navigation is how the reader moves, not what they came to read: a component of it whose failure leaves it non-functional goes, and says nothing. The dispatch already decided how a failure reports by what the host is (ldh:RenderFailure), so the navigation hosts get rules of their own there. A host under the drawer removes the section it sits in - removed rather than hidden, so the sections' dividers stay right and ldh:NavigationUpdate stops re-asking a refused query on every navigation, since it finds no section to fill; a reload brings it back. The breadcrumbs keep the crumbs that loaded and stop at the ancestor that could not be read, which a reader who cannot read it could not have navigated to either. Both branches that used to draw their own failure row now go through ldh:render-failure, so the same callback serves the drawer's tree and the taxonomy package's concept tree in the content: one loses its section, the other keeps its inline row. alert(), the last resort for a host that has left the page mid-request, moves from the function into the dispatch as a rule ranked under navigation, so a closed drawer or a closed tab degrades the same way an open one does. Content is unchanged: blocks, views, forms and dialogs report inline as before. Measured against the dev stack with nothing made public: on load the refused class-types query removes the Classes section, and expanding Root removes the Document tree; search and Other remain, without a stray divider, and no alert, dialog or console error. The full ui-tests suite passes (68, with the suite's 44 declared skips), tree-children-failure now asserting the section's absence for the owner and the anonymous reader alike. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hed through itself. Importing such a package answered 504: resolving its description was an HTTP GET of a document this application serves, and serving it runs the ontology filter, which finds the ontology the settings update just evicted, resolves the package descriptions in turn and issues the same GET. The requests nested until nginx cut the outermost one, the import having been written all along. getPackage now recognises a description whose document belongs to one of this instance's applications and reads that named graph from the application's store; only foreign URIs are dereferenced. The filter also resolves the descriptions before taking the union lock, which is for serialising the build, not for holding every cold request behind a package server. A new http-test imports a stylesheet-only package described on the instance and requires the settings PATCH and the first render after it to answer within 30 s. The containment test's stylesheet fixture is resolved beside the script, where the runner executes it, so CI can find it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and hands them to the end-user query, instead of the end-user store joining them in through SPARQL SERVICE. A deployment that isolates its stores refuses that call - an egress proxy denying internal addresses answers it with 403 - and the entrypoint then fed the error body to xsltproc, which exited 6 and, under set -e, took the container with it. public-rules.rq selects each public rule's document or class, subclasses included through the union graph as in the ACL query, together with the base of the dataspace the rule governs, derived from its graph as the admin origin minus admin. The entrypoint writes those bindings into sitemap.rq.template as two VALUES blocks and POSTs the result to the end-user service, so the join still runs in the store, against its indexes, while only the rules travel. Generation runs in a subshell function behind if !, so a failure is logged with curl's own message and the platform starts without a sitemap rather than not at all. (#386) The listing also becomes the set anonymous agents can actually read. A rule now counts only within its own dataspace, where before any dataspace's public class rule matched every dataspace's documents in a shared store; the subclass path now sees the ontologies, where it had been confined to the rule's own graph; and GROUP BY with max() lists each document once with its latest date, where it repeated once per matching rule and date. On linkeddatahub.com's dev data that is 5,732 URLs instead of 5,741, the nine dropped being the unpublished dataspaces' own documents, which answer 403; production's sitemap there carries 132,528 entries for 11,044 distinct URLs. On Rebrickable's 15M quads the block takes 8.2 s for 158,626 URLs. sitemap.xsl is unchanged: the result still binds ?loc and an xsd:dateTime ?lastmod. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…QL SERVICE tests (#387) * A SPARQL SERVICE clause sent to /sparql must not reach the deployment's internal services, and two HTTP tests now say so. The triplestore executes SERVICE, so an internal target hands the agent data the ACL never grants, such as the admin store's agents and authorizations. GET-sparql-service-internal.sh aims SERVICE at the admin store, at the admin store through varnish-admin, and at a store over localhost; POST-sparql-service-internal.sh sends the admin-store query in the request body. Both use SERVICE SILENT, which turns a refused call into a single empty solution, so they check the results for a bound ?g rather than the response for a status code, and do not depend on how the platform reports a failed federated call. Each target was checked against a throwaway backend with LinkedDataHub's service names, the end-user Fuseki run three ways: with no outbound proxy every target reports a leak; behind a Squid egress proxy refusing internal addresses every target passes; and behind that proxy without an emptied http.nonProxyHosts, fuseki-admin and varnish-admin pass while localhost still reports a leak - the JVM's default loopback exemption, and the reason that target is there. The default stack has no egress proxy, so both tests fail against it until one is added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * The triplestores' outbound requests go through an egress proxy that only lets them reach public addresses, which closes the hole GET- and POST-sparql-service-internal.sh document. Fuseki executes SPARQL SERVICE from inside the stack, so a query to /sparql could name http://fuseki-admin:3030/ds/ and read the admin store's agents and authorizations, as the HTTP suite showed against the default stack. Federation has to keep working, so SERVICE stays enabled; instead the new egress service, Squid with the rules inline like nginx's and Varnish's configs, refuses loopback, private and link-local destinations, resolving each where it connects so a redirect hop or a DNS answer is checked too. Both stores point their JVM at it through JAVA_TOOL_OPTIONS - JAVA_OPTIONS already carries the heap, and an override setting it would silently drop the proxy - with an empty http.nonProxyHosts, since the JVM otherwise exempts localhost and a store could still be read over loopback. EGRESS_PROXY_HOST renames the proxy for deployments that alias their services on a shared network. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orts, PATCH) (#388) * A SPARQL SERVICE clause in a PATCH update or an import mapping must not reach the deployment's internal services, and two HTTP tests now say so. Unlike a query to /sparql, these run in the platform's own JVM - PATCH executes the update on the document's graph in memory, an import runs the mapping over each row - so the egress proxy in front of the triplestores never sees the call, and an internal target hands a writer the admin store's agents and authorizations. PATCH-service-internal.sh patches an item with INSERT { <item> <urn:test:leak> ?g } WHERE { SERVICE SILENT <http://fuseki-admin:3030/ds/> { ... } } and fails if the leak triple lands in the document; import-rdf-service-internal.sh imports with rdf-service-internal.rq, a CONSTRUCT that always mints the item and gives it the leak triple only if the admin store answered. SERVICE SILENT turns a refused call into one empty solution, so both check the document for data rather than the response for a status code. The leak is real on the current code: reproduced against a live stack whose triplestore already sits behind the egress proxy, a PATCH SERVICE to the admin store copied an admin graph name into the patched document. The default stack has no restriction on SERVICE in these paths, so both tests fail against it until the platform routes or refuses these calls itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * The platform routes its own SPARQL SERVICE requests through the egress proxy, closing the leak PATCH-service-internal.sh and import-rdf-service-internal.sh document. A PATCH update runs on the document's graph in memory and an import runs its mapping over each row, both in the platform's JVM, so the proxy in front of the triplestores never saw those SERVICE calls and a writer could name an internal service and copy the admin store's agents and authorizations into a document they read. Application.configureServiceExecution now sets, on the global ARQ context the in-JVM query and update execution reads, either Jena's httpQueryClient - an HttpClient whose ProxySelector sends every SERVICE request through egress, localhost included, so federation with public endpoints keeps working while internal addresses are refused with redirect hops and DNS answers checked - or, when no proxy is configured, httpServiceAllowed=false so SERVICE is disabled rather than left open. It is scoped to SERVICE alone: the platform's own SPARQL calls to its stores use their own HTTP clients, and ALLOW_INTERNAL_URLS leaves SERVICE unrestricted as it already disables the rest of the SSRF protection. EGRESS_PROXY (default egress:3128) reaches the JVM as a system property through CATALINA_OPTS, the pattern the other infrastructure settings already use rather than the parameter-saturated context.xsl plumbing, and the linkeddatahub service depends on egress. Verified: on an in-memory QueryExecution like PATCH and imports build, httpServiceAllowed=false makes SERVICE SILENT yield no triples and a non-silent SERVICE throw QueryDeniedException, and a ProxySelector client sends the request to the proxy that then allows public and denies internal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…at it.
Generation ran once, after the app loop, against the root services - every other app's endpoint was overwritten on the next iteration and lost - and the single file it wrote was mapped to Tomcat's default servlet, which dispatches on path and never on Host. Both wirings of system.trig were wrong in opposite directions: a deployment isolating its stores left every other dataspace without a sitemap, while one sharing them, as this repo ships, served each origin a sitemap carrying another origin's documents. The location of a sitemap determines the URLs it may contain, so the second is the one that breaks the protocol; only the absence of a public grant outside root was keeping it out of sight.
The app loop now keeps each app's endpoint and credentials keyed by origin, and a pass after it walks the end-user apps, pairing each with the admin app of its dataspace and writing a sitemap named after its host. Both origins come from dataspaces.trig, so the queries take the bases as parameters: public-rules.rq restricts the authorization graphs to the admin base rather than reverse-engineering the dataspace from the graph name with replace(), and sitemap.rq.template filters the documents by the end-user base, which leaves both VALUES blocks a single column. The queries also carry the service credentials, as every other store call in the entrypoint already did - against a store that requires authentication they were answered with 401, and the platform started with no sitemap and said only that it could not generate one.
A dataspace whose admin app is absent is logged and skipped, since only the root pairing is required; one with nothing public writes no file, and any file left from when it had is removed. A robots.txt is written beside each, for admin dataspaces too: having a sitemap is what makes a dataspace crawlable, so one without a sitemap to name disallows everything.
Serving stays below the application. Neither file is a document of the platform's model - no description, no named graph, no ACL - so neither becomes a JAX-RS resource; a RewriteValve reads WEB-INF/rewrite.config and rewrites both paths to the file generated for the requested host, %{HTTP_HOST} resolving to the host without the port, which is how the entrypoint names them. The files are mounted under /static/sitemaps from outside the WAR the way the composed stylesheets already are, so they survive a redeploy. Nothing needs an authorization, since a sitemap lists only what a public rule already exposes.
http-tests/dataspaces/sitemap.sh drives both dataspaces of the test stack, which share their stores, and fails on a <loc> or a Sitemap: line belonging to the other one. The fixtures had no publicly readable end-user document, so root-owner.trig.template grants each dataspace's root document public read through a class rule - the shape whose match can cross a shared store.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t stays in the content column. Auto-flow placed the aside in column 1 of row 1 and then kept filling column 1 on every row after it, so the second resource a document describes landed in the 300px gutter, squeezed to 300px wide - the layout was only ever right for a document describing one. Only the create dock was placed explicitly; every child names column 2 now, and the narrow stop has to undo that, since a child still pinned to column 2 conjures an implicit second column out of a one-track grid. The tree beside it scrolled away after the first resource: align-items: start had shrink-wrapped the aside to the tree's own height, leaving the sticky card nothing to travel in. The aside now spans every row and fills what it spans. The span is a literal because there is nothing else to write - 1 / -1 counts back from the end of the EXPLICIT grid, which has no rows here, so it resolves to a single row - and the size of it costs nothing measurable (forced layout over 40 block rows: 0.02 ms at span 50, 0.03 ms at span 9999). It stays IN FLOW on purpose: absolutely positioned it contributed no height, and a tree taller than a short document's content painted over the footer. row-gap goes to zero, which is the content body's own spacing rather than a reduction of it: without an aside .content-body is a flex column whose block rows carry no margin and sit flush, which is what the drop marker is drawn against. The gap shorthand was separating rows by --sp-5 in the two-column form and nowhere else, and a row gap applies between every implicit row, so it would have been multiplied by the span. Travelling, the card then parked at the kit's own top: var(--sp-4) - behind 117px of sticky chrome. .ldh-onto-list reads top: var(--ldh-aside-top, var(--sp-4)) and the slot supplies that token, composed from the bar tokens the way .editor-bar composes them so it tracks the tab bar appearing instead of restating a height. The kit's value stays the fallback, so the ontology editor's use of the same card is untouched. app.css and preview/component-content-aside.html are pushed to the design system. The card gained a demo with two resources; every demo it had showed one content child, which is why none of this ever showed there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s what it cannot cover.
It asked for 200 on /sitemap.xml and got 404. The container log says why: "No public documents in <https://localhost:4443/>, writing no sitemap" for both dataspaces, and the query Fuseki logged carries "VALUES ?Type { }" - the class rules came back empty while the document rules came through. The grant meant to supply one was added to http-tests/root-owner.trig.template, which the entrypoint never reads: it renders "root-owner.trig.template" relative to CATALINA_HOME, where the Dockerfile copies the platform's own, while docker-compose.http-tests.yml mounts the override at /var/linkeddatahub/root-owner.trig.template. Neither that file's public-root grants nor its pre-existing owner-webid, owners-group and test-owner-control graphs appear anywhere in the run, and nothing has missed them, because the entrypoint writes an owner authorization per admin app itself. The grant is reverted rather than left in a file nothing loads.
So the script now pins the serving contract, which is what the change is for: both paths answer from a file named after the requested host, a configured origin answers and an unconfigured one does not, and nothing goes through the application. Every origin of this stack disallows crawling and has no sitemap, since no fixture makes an end-user document publicly readable. The assertion the two dataspaces exist for - both wired to the same stores, so neither sitemap may carry the other's documents - cannot run there, and the script records that in place of skipping it silently.
write_robots prints what it wrote, so the next failure of this kind is readable from the startup log rather than inferred from the queries the store logged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stack. fuseki-admin and fuseki-end-user become a single fuseki service mounting ./fuseki, whose config/fuseki/config.ttl declares an end-user and an admin dataset per dataspace over their own TDB2 folders - named after the dataspace origin with the deployment host dropped and the role appended: end-user, admin, northwind-traders.demo.end-user, northwind-traders.demo.admin. system.trig gives northwind-traders its own services in place of reusing root's, and points every service at fuseki:3030/<dataset>/; each dataset carries the 60 s query timeout on its query endpoint. The heap and mem_limit are the former two combined (2304m in 4608m). No entrypoint change: it already loads each app into the store URL its own service declares, and CI/dev start empty and load fixtures. (#389) varnish-admin and varnish-end-user are kept, both now fronting the single fuseki (their VCL .host default becomes fuseki): the admin/end-user split is a boundary the platform already routes on, the two role VCLs stay cohesive (admin xkey/XKEY-PURGE, end-user query-ban) and their separate storage pools keep end-user query churn from evicting the hot admin cache - merging would rebuild that isolation inside one container by parsing dataset names. egress is unchanged; both stores already route SERVICE/LOAD through it. The tests follow the single server. http-tests/config/system.trig points root and the test dataspace at fuseki:3030/end-user/ and /admin/ - test keeps sharing root's datasets, so the acl/* tests still exercise the platform's base-URI filtering of a fake admin.test.localhost authorization rather than relying on physical separation. run.sh, the http-tests and debug compose overrides, ui-tests/lib/stack.mjs and bin/sitemap/generate-sitemap.sh drop the 3031 end-user port and address datasets by path on the one 3030; the internal-SERVICE tests (GET/POST-sparql-service-internal, PATCH-service-internal, import-rdf-service-internal, GET-proxied-internal-403) target fuseki:3030/admin/ so they still prove an internal dataset is refused. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…rm's generic value rendering. The generic value leaves - the property row, the value cell, the form controls, the annotations, the type and label lookups - lived in imports/default.xsl, which common.xsl imports above the package stylesheets, so core's */* form control beat a package's rule for skos:prefLabel however the package ranked it. They move, bodies verbatim, to imports/values.xsl, imported by hooks.xsl and therefore below the packages; the one control typed on the dh: document slug stays sealed. The file could not simply move down: it also declares the keys, global params and functions the platform is made of, and a module below the packages is overridable in everything it declares. Those stay in imports/default.xsl, with the component emitters, the combobox widget and the RDF/XML rewriters, above the packages. The marker does not change, so neither composer does. The containment probe now also replaces the property-list cell of dct:title, on a document whose topic has a title, and the test asserts the cell marker with the import and not without: the positive proof beside the negative ones. Verified on the rebuilt dev stack on both render paths, including the client-rendered edit form, where the package's form control decorated the title's and next-match kept the platform's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
No description provided.