fix(workspace): level two-column cards, window the Reusable tags list, full-width System (#530)

D182: the second .two-col card no longer takes the 18px .surface + .surface margin.
D183, D185: Reusable tags scrolls in its own window with a sticky header; the end of the
list inside the window loads the next page and Load more stays below it.
D184: System sections use the full content width.
This commit is contained in:
Vito Sansevero
2026-10-30 09:39:19 -07:00
parent a6d6129ec6
commit ff98f33079
8 changed files with 238 additions and 18 deletions
+13 -8
View File
@@ -79,12 +79,12 @@ function categoryRow(category) {
/**
* One bounded list: rows append as pages arrive, the end of the list loads the
* next page each time it scrolls into view, and `Load more` stays available while
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
* next page each time it scrolls into view (of `scroller` when the list has its own window, D183), and `Load more`
* stays available while more remain. Only a new intersection appends: when scroll anchoring keeps the end
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
* failed read never looks like zero counts.
*/
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query}) {
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query, scroller = null}) {
const copy = COPY[kind];
const empty = el('p', copy.empty, 'empty');
const continuation = el('div', undefined, 'vocabulary-continuation');
@@ -96,6 +96,9 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
const more = createButton(copy.more);
continuation.append(status, alert, retry, more);
after.after(empty, continuation);
// D183: a list with its own window (`scroller`) ends inside that window; Load more stays reachable below it.
const end = scroller ? el('div', undefined, 'vocabulary-end') : continuation;
if (scroller) scroller.append(end);
const items = new WeakMap();
const listeners = new Set();
let rendered = 0, first = null;
@@ -103,14 +106,16 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
function maybeLoad() {
const {state} = stream;
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
const bounds = end.getBoundingClientRect();
const area = scroller?.getBoundingClientRect() ?? {top: 0, bottom: window.innerHeight};
if (bounds.top <= area.bottom + LOAD_MARGIN && bounds.bottom >= area.top - LOAD_MARGIN) stream.loadMore();
}
function render(state) {
// A reset starts again from page one; otherwise pages only append.
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
rowsHost.replaceChildren();
rendered = 0;
if (scroller) scroller.scrollTop = 0;
}
const appended = [];
for (const item of state.items.slice(rendered)) {
@@ -146,8 +151,8 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
}, {rootMargin: `${LOAD_MARGIN}px`});
observer.observe(continuation);
}, {root: scroller, rootMargin: `${LOAD_MARGIN}px`});
observer.observe(end);
}
scope.cleanup(() => {
stream.dispose();
@@ -245,7 +250,7 @@ export function mountVocabulary(main, api, scope) {
grid.append(tagSurface, categorySurface);
main.append(grid);
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read()});
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read(), scroller: wrap});
let announcing = false;
tags.subscribe(state => {
if (state.untaggedCount !== null) untagged.row.querySelector('p').textContent = countOf(state.untaggedCount, 'positive prompt');
+14
View File
@@ -107,6 +107,12 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.stat dd { font-size: 26px; }
}
/* D182 (#530, the concept has the offset): the second card of a `.two-col` starts level with the first. The grid gap
already spaces them; `.surface + .surface` is for surfaces stacked in plain flow. */
.two-col > .surface + .surface { margin-top: 0; }
/* D184 (#530, the concept stops System at 1000px): every System section uses the full content width, as other pages do. */
.settings-layout { max-width: none; }
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
@@ -118,6 +124,14 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
/* D183 (#530, beyond the concept, whose sample list is short): Reusable tags scrolls in its own window and the page keeps
its height. The first term is the smallest usable window; the subtraction leaves the heading, the card's own rows and
Load more on screen. D185: on touch and at 390px the window stays shorter than the screen and overscroll chains to the
page (the default), so a swipe is never trapped in the list. A collapsed border does not stick, so the header draws its
line instead of its border. */
.vocabulary-main .table-wrap { overflow: auto; max-block-size: max(280px, calc(100dvh - 500px)); scroll-padding-block-start: 44px; }
.vocabulary-main .table-wrap thead th { position: sticky; inset-block-start: 0; z-index: 1; background: var(--ca-surface); border-bottom-color: transparent; box-shadow: inset 0 -1px 0 var(--line); }
.vocabulary-main .vocabulary-end { block-size: 1px; overflow-anchor: none; }
@media (max-width: 700px) {
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */