Skip to content

Node identity and parentage are XPath, not DOM calls - #31

Open
namedgraph wants to merge 1 commit into
masterfrom
xpath-over-dom-reads
Open

namedgraph wants to merge 1 commit into
masterfrom
xpath-over-dom-reads

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Nine reads in the editor reached through ixsl:call/ixsl:get for facts the data model already exposes, so the modules were asking JavaScript questions XPath answers:

was now where
ixsl:call($a, 'isSameNode', [ $b ]) $a is $b annotate.xsl ×2, edit.xsl
ixsl:get($n, 'parentNode') .. annotate.xsl, edit.xsl ×2
ixsl:get($n, 'parentElement') parent::* navigate.xsl
ixsl:get($n, 'nodeType') = 3 instance of text() annotate.xsl
ixsl:call(ixsl:window(), 'Object', []) ixsl:new('Object', []) index.xsl, typeahead.xsl
three-line JS options object map{ 'block': 'nearest' } typeahead.xsl

parentElement is null when the parent is not an element, which is exactly what parent::* selects, so that one is a faithful swap rather than a near-one.

The two event targets carrying identity tests take as="node()?" so the comparison has a node to compare — the same typing relatedTarget already had in navigate.xsl, which is why is was reachable there and not here.

typeahead.xsl was allocating an object, setting block on it and passing it to scrollIntoView; navigate.xsl passes an XDM map literal to the same method a few lines from its own call, so the map form was already the house idiom.

What deliberately does not move

The DOM layer proper. Building detached subtrees through rdfae:element and splicing them with insertNode/appendChild/before stays as it is: xsl:result-document emits at a location and hands back nothing to hold, while the insertion model passes the constructed node onward to be spliced, to receive the caret and to seed a Range. That is an architecture, not a workaround, and converting it would be a redesign of the editing core rather than a cleanup.

Verification

  • make sef — both entries compile
  • make test — 52 pass, 0 fail
  • make test-browser — 532 assertions true; the two canonicalSource paste-probe values that report false are unchanged from master (verified by stashing and re-running)

🤖 Generated with Claude Code

https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd

Nine reads reached through ixsl:call/ixsl:get for facts the data model
already exposes, so the module asked JavaScript a question XPath answers.
isSameNode($a, $b) is `$a is $b`; parentNode is `..`; parentElement, which
is null when the parent is not an element, is exactly `parent::*`; and a
Range container's nodeType = 3 test is `instance of text()`. The event
targets that carried two of the identity tests take `as="node()?"` so the
comparison has a node to compare - the same typing relatedTarget already
had in navigate.xsl, and the reason `is` was reachable there and not here.

Two constructions follow: Object() through ixsl:call is ixsl:new('Object',
[]), the extension that exists for it; and typeahead's three-line scroll
options object - allocate, set `block`, pass - is one XDM map literal, the
form navigate.xsl was already passing to the same scrollIntoView on the
line above its own call.

Nothing moves in the DOM layer proper. Building detached subtrees through
rdfae:element and splicing them with insertNode/appendChild/before stays:
xsl:result-document emits at a location and returns nothing to hold, and
the insertion model hands the constructed node onward to be spliced, to
receive the caret and to seed a Range. That is an architecture, not a
workaround, and converting it would be a redesign of the editing core.

make sef compiles both entries; make test passes 52; make test-browser
reports 532 assertions true with the two pre-existing canonicalSource
paste-probe values unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSRUJBNKKjdgJeP3qRZTPd
namedgraph added a commit to AtomGraph/LinkedDataHub that referenced this pull request Sep 11, 2026
…L stops asking JavaScript what XPath already knows

Paginating a view, or reversing its sort, collapsed the whole block to a
placeholder and back, and took the pagination controls down with it - the
button the reader had just pressed vanished from under the cursor for as
long as the query took. Two causes, both structural.

The pager was emitted INSIDE .container-results, by each of ldh:ListViewBlock,
ldh:TableViewBlock and ldh:GridViewBlock beside their rows. That div is what
ldh:RenderViewMode replaces on every mode, facet, sort and page change, so a
control was a child of the thing it re-renders. It now has its own host -
#<view>-pager, rendered once beside the results region by ldh:RenderViewResults
and filled by ldh:RenderPager - so a results swap cannot reach it, and
ldh:result-count-response addresses it by id instead of descending into the
region. The $show test moves into ldh:RenderPager and picks up the mode check,
so switching to the map empties the host the list left behind rather than
stranding it. This is also the design system's own anatomy for a paged view:
rows and pager as siblings of the block body, which app.css has laid out all
along and which ldh.css was bridging around with position: sticky plus three
surface-matching backgrounds - compensation for the pager scrolling among the
rows, and now deleted along with the reason for it. Nothing scrolls under the
pager any more, so it needs no opaque surface, which is what made the nblock
overrides removable rather than merely movable.

The other cause was ldh:show-view-skeleton, which emptied the results region
on every re-query and appended a fixed FOUR-row ldh:BlockSkeleton regardless
of page size: twenty rows became four and back on every click. A skeleton
stands in for content whose shape is known but which is not yet drawn, and a
re-query's rows are already on the screen. ldh:begin-view-refresh leaves them
standing and marks the region busy - dimmed and inert, so the height cannot
move and a second page request cannot be queued on the first - cleared in
ldh:RenderViewMode on success, in ldh:promise-failure for the chain that dies
after the results land (a metadata request rejecting reaches neither the
render nor the results error handler, and would have left a view dimmed for
the session). ldh:BlockSkeleton loses its only caller and goes; the kit's
.ldh-block-loading stays in app.css for a block whose shape is known and
undrawn, which this never was.

Upstream in the design system (published to the Claude Design project, and
app.css re-vendored from it): .ldh-pager:empty is display: none, and the
paged-view layout re-scopes from :has(> .ldh-pager) to
:has(> .ldh-pager:not(:empty)). The kit's README promised "an unpaged view is
untouched", which only held while the pager was conditionally rendered; a
client that renders the host up front - which it must, or the control vanishes
- has one on every view, so the bare :has() capped and scrolled bodies that
never page, and the empty host drew its padding, margin and top border as a
stray rule under the rows. Measured: 25px strip and max-height 620px before,
0px and none after.

While in the client XSL, the DOM calls that have ixsl or XPath equivalents
retire. ixsl:remove-attribute for six removeAttribute calls, ixsl:set-attribute
for seven setAttribute, ixsl:set-style for the block bar's --p, ixsl:new for
Reflect.construct-by-name, ixsl:remove-property for Reflect.deleteProperty,
ixsl:json-parse for three JSON.parse, ixsl:style for getComputedStyle (it IS
window.getComputedStyle - checked in the SaxonJS source, not the docs),
ixsl:location for window.location.href and .hash, xsl:result-document for an
append of constructed markup, and plain XPath for nine ixsl:get(node, 'id')
reads and two Node.contains tests - the latter the idiom navigation.xsl was
already using a few lines away.

classList goes the same way, all 85 sites. ldh:set-token($class, $token, $on)
returns @Class with the token present iff $on, which covers add (true()),
remove (false()) and - this is why one function suffices - the two-argument
classList.toggle(token, force), which 47 of the 48 toggles were. The one
genuine flip, the 3D canvas fullscreen, spells it not(contains-token(...)).
distinct-values rather than a concatenation: a token already present keeps its
position and is not repeated, which is what classList.add promises and what
@Class || ' x' quietly breaks on the second call. Class state is now one
vocabulary - contains-token() to read, ldh:set-token() to write - where it had
been XPath on one side and a DOM object on the other.

The vendored rdfa-editor copies take the same substitutions (isSameNode is
`is`, parentNode is `..`, parentElement is `parent::*`, nodeType = 3 is
`instance of text()`, plus ixsl:new and an XDM map literal for scrollIntoView
options), applied in place rather than re-vendored, because those seven files
carry LDH's translation layer that a wholesale copy would destroy. Filed
upstream as AtomGraph/RDFa-Editor#31. RDFa2RDFXML-v3.xsl takes upstream's
comment wording, the one hunk in that tree not explained by the translation
divergence; the vendored copies are otherwise byte-identical or
translations-only, verified hunk by hunk.

Verified rather than assumed. The busy state holds geometry exactly - block
677px to 677px across the refresh and across a page flip, results 427px
unchanged, pager fixed at top 613 and never hidden - measured in headless
Chromium against the four stylesheets in link order, along with a 100-row
page scrolling inside the region with the pager still fully visible, an
unpaged view back to display: block and max-height: none, and a view nested
in a well getting a transparent static pager below its scrollport.
ldh:set-token passes 14 cases on SaxonJS including prefix safety
(is-open-wide is not is-open), idempotent add and an absent @Class. And
because several sites write class three times in a row, each re-reading
@Class, the composition was checked in a real browser with the shipped
SaxonJS runtime: ixsl:set-attribute calls setAttribute() immediately, no
pending update list, so the writes compose exactly as the classList calls
did. The SEF compiles clean.

Not yet live: the LTLOD image bakes the WAR, so the click-through rides the
next rebuild.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant