Thank you @Roko C. Buljan for your help (I wanted to reply as a comment on your post, but my example was too long).
Your approach using :scope and :not() works well for the initial example, but I encountered an issue when applying it to a specific parent element.
If I call getDirectChildren(document.querySelector("#layout-3")) on the initial example, it does not return #layout-4 as expected. I think this happens because the selector :scope .layout:not(.layout .layout) only considers direct .layout elements relative to the original parent (.parent in the example), but it does not properly update when parent itself is a .layout.
Here is an extended example demonstrating the issue:
const getDirectChildren = (parent, sel = ".layout") =>
[...parent.querySelectorAll(`:scope ${sel}:not(${sel} ${sel})`)];
// Example 1 : Should print [#layout-1, #layout-2, #layout-7]
let app = document.querySelector(".app");
console.log("App descendant children", getDirectChildren(app)); // Result: Correct!
// Example 2 : Should print [#layout-3, #layout-4, #layout-6]
let layout2 = document.querySelector("#layout-2");
console.log("Layout 2 descendant children", getDirectChildren(layout2)); // Current result: []
// Example 3 : Should print [#layout-5]
let layout4 = document.querySelector("#layout-4");
console.log("Layout 4 descendant children", getDirectChildren(layout4)); // Current result: []
<div class="app">
<div>
<div class="layout" id="layout-1"></div>
</div>
<div>
<div class="layout" id="layout-2">
<div class="layout" id="layout-3"></div>
<div>
<div class="layout" id="layout-4">
<div>
<div class="layout" id="layout-5"></div>
</div>
</div>
<div class="layout" id="layout-6"></div>
</div>
<div></div>
</div>
</div>
<div class="layout" id="layout-7"></div>
</div>
This shows that while the function works at the top level, it fails to return children when applied to .layout elements deeper in the hierarchy.