Skip to content

CLARIN-DSpace v9/Port #1350 (admin-sidebar gutter via CSS custom properties) to the v9 base - #1512

Merged
milanmajchrak merged 1 commit into
dtq-dev-9-basefrom
ufal/port-1350-9-base
Sep 10, 2026
Merged

CLARIN-DSpace v9/Port #1350 (admin-sidebar gutter via CSS custom properties) to the v9 base#1512
milanmajchrak merged 1 commit into
dtq-dev-9-basefrom
ufal/port-1350-9-base

Conversation

@milanmajchrak

Copy link
Copy Markdown
Collaborator

What

Ports dataquest-dev/dspace-angular #1350 (1321b8acc0, "UFAL/Backport #1333: admin-sidebar
gutter via CSS var (no logged-in reload shift)") to dtq-dev-9-base. Sync card FE-13 (tranche T3).

For an authenticated user, a hard reload shifted the whole page right by the admin-sidebar width.
The .outer-wrapper left gutter was produced by the @slideSidebarPadding animation, whose params
come from CSSVariableService — a browser-only store populated from document.styleSheets. Under
SSR that store is empty, so the server emits padding-left: 0 and the browser resolves the real width
only afterwards. Measured on the current deployment (dev-6:8603, anonymous /home):

$ curl -s http://dev-6.pc:8603/repository/home | grep -o '<div[^>]*outer-wrapper[^>]*>' | head -1
<div _ngcontent-dspace-angular-c4194216939="" class="outer-wrapper ng-tns-c4194216939-0 ng-trigger ng-trigger-slideSidebarPadding" style="padding-left: 0;">

The gutter is now a CSS class — ds-admin-sidebar-{hidden,unpinned,pinned}, derived from a small
sidebarPaddingState$ — whose padding-left resolves from the --ds-admin-sidebar-* custom
properties. Those live in the render-blocking theme stylesheet, so CSS resolves the gutter
identically on the server and in the browser, with no hardcoded width and no browser-only read:

$ curl -s http://dev-6.pc:8603/repository/dspace-theme.<hash>.css \
    | grep -oE -e '--ds-admin-sidebar-(total-width|fixed-element-width):[^;]*' | sort -u
--ds-admin-sidebar-fixed-element-width: 55px
--ds-admin-sidebar-total-width: 305px

The pin/unpin slide is preserved by transition: padding-left 300ms ease-in-out, gated behind
ds-admin-sidebar-animate, which ngAfterViewInit enables only after the first paint — so the initial
SSR→CSR gutter resolution never animates.

Changes

File Change
app/root/root.component.ts slideSidebarPadding import + animations: entry removed; sidebarPaddingState$, outerWrapperClasses$, gutterTransitionEnabled added; implements AfterViewInit with a requestAnimationFrame first-paint hook
app/root/root.component.html [@slideSidebarPadding] replaced by [class.ds-admin-sidebar-animate] plus a merged [ngClass]
app/root/root.component.scss .outer-wrapper.ds-admin-sidebar-{hidden,unpinned,pinned,animate} rules appended
themes/custom/app/root/root.component.ts the 2 slideSidebarPadding lines dropped (parity with the source commit)
app/root/root.component.spec.ts 2 new cases — beyond the source commit, which ships no spec

src/app/root/ and src/themes/custom/app/root/ on this branch are byte-identical to vanilla
dspace-9.3 (git diff dspace-9.3 origin/dtq-dev-9-base -- src/app/root/ → empty), so this fixes a
vanilla-v9 bug locally. Flagged for the human upstream triage; nothing was sent upstream.

Three adaptations from the 7.x hunks, spelled out

  1. [ngClass] already exists here — merged, not replaced. 9-base's .outer-wrapper carries
    [ngClass]="browserOsClasses.asObservable() | async", which 7.x does not have. Angular allows one
    [ngClass] per element, and pasting the source's
    [ngClass]="'ds-admin-sidebar-' + (sidebarPaddingState$ | async)" would silently drop
    browser-firefox / browser-firefox-windows, which _custom_variables.scss uses
    (.browser-firefox-windows { --ds-dark-scrollbar-width: 20px }). Hence one merged
    outerWrapperClasses$ stream. This is not hypothetical — see negative control NC-2 below,
    where pasting the source hunk verbatim really does render
    [ 'outer-wrapper', 'ds-admin-sidebar-hidden' ] with the browser classes gone.
  2. <ds-admin-sidebar>, not <ds-themed-admin-sidebar> — the source hunk's context line is the
    7.x tag; this branch's tag is left unchanged.
  3. Two source hunks are no-ops here: the trailing-comma reformat of the windowService
    constructor parameter is already present, and AfterViewInit joins an already multi-line
    @angular/core import. Net: the source's 40+/5− in the TS becomes 54+/3−, the +14 being the
    outerWrapperClasses$ declaration (7) and assignment (6) plus a 3-line requestAnimationFrame
    body instead of the source's 1-line one.

slideSidebarPadding is deliberately kept in src/app/shared/animations/slide.ts: after this
change it still has one consumer,
src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts:6,17.

Tests added beyond the source commit

The source commit adds no spec, and this branch's root.component.spec.ts had a single
it('should create') — a gate that cannot detect this regression. Two cases added:

test asserts
should emit the hidden gutter state when the admin sidebar is not visible sidebarPaddingState$ emits 'hidden'; outerWrapperClasses$ equals ['browser-firefox', 'browser-firefox-windows', 'ds-admin-sidebar-hidden']; .outer-wrapper carries all three in the DOM
should enable the gutter transition only after the first paint .outer-wrapper carries ds-admin-sidebar-pinned immediately but not ds-admin-sidebar-animate; ngAfterViewInit registered a requestAnimationFrame callback; invoking it flips both the flag and the class

The second test stubs requestAnimationFrame and drives the callback by hand, so it does not depend
on a real paint landing between jasmine callbacks.

Testing

$ npm run test:headless -- --include='src/app/root/root.component.spec.ts' --code-coverage=false
  RootComponent
    ✔ should create
    ✔ should enable the gutter transition only after the first paint
    ✔ should emit the hidden gutter state when the admin sidebar is not visible
TOTAL: 3 SUCCESS

Negative controls (production code broken one at a time, tests untouched; each gave
TOTAL: 1 FAILED, 2 SUCCESS):

  • NC-1 drop the !visible → 'hidden' guard → Expected 'pinned' to equal 'hidden'.
  • NC-2 paste the source's literal [ngClass] instead of merging →
    Expected [ 'outer-wrapper', 'ds-admin-sidebar-hidden' ] to contain 'browser-firefox'.
  • NC-3 gutterTransitionEnabled = true initially → Expected true to be false. plus
    … not to contain 'ds-admin-sidebar-animate'.
  • NC-4 remove the requestAnimationFrame hook → Expected 0 to be greater than 0. plus
    … to contain 'ds-admin-sidebar-animate'.

Lint, circular-dependency check, production build and the server-side render check are pasted in the
gate comment below.

Live verification (card AC-FE-13-3 / manual scenario UNIVERSAL-028) is pending deployment. Note the
card's own probe is a permanent false pass and must be replaced: grep -c 'ds-admin-sidebar-' already
returns 1 today, because the SSR <style> block inlines eight --ds-admin-sidebar-* property
names and grep -c counts lines in a 517871-byte, 21-line document. The discriminating probe is:

BASE=http://dev-6.pc:8603/repository
curl -s "$BASE/home" | grep -o '<div[^>]*outer-wrapper[^>]*>' | grep -oF 'ds-admin-sidebar-hidden' | wc -l          # expect 1 (before: 0)
curl -s "$BASE/home" | grep -o '<div[^>]*outer-wrapper[^>]*>' | grep -oF 'ng-trigger-slideSidebarPadding' | wc -l   # expect 0 (before: 1)

Source: 1321b8acc0 (dtq-dev PR #1350). Sync card: FE-13.

🤖 Generated with Claude Code

…er via CSS var (no logged-in reload shift)

For an authenticated user a hard reload shifted the whole page right by the
admin-sidebar width. The `.outer-wrapper` left gutter was produced by the
`@slideSidebarPadding` animation, whose width comes from `CSSVariableService`,
a browser-only store fed from `document.styleSheets`. On the server that store
is empty, so SSR emitted `style="padding-left: 0;"` and the browser resolved the
real width afterwards - hence the jump. Measured on dev-6 before this change:

  <div _ngcontent-... class="outer-wrapper ng-tns-c4194216939-0 ng-trigger
       ng-trigger-slideSidebarPadding" style="padding-left: 0;">

The gutter now comes from a CSS class - `ds-admin-sidebar-{hidden,unpinned,pinned}`,
derived from a small `sidebarPaddingState$` - whose `padding-left` resolves from
the `--ds-admin-sidebar-*` custom properties. Those are defined in the
render-blocking theme stylesheet (`--ds-admin-sidebar-fixed-element-width: 55px`,
`--ds-admin-sidebar-total-width: 305px` on dev-6), so CSS resolves the gutter
identically on the server and in the browser, with no hardcoded width and no
browser-only variable read. The pin/unpin slide is preserved by
`transition: padding-left 300ms`, gated behind `ds-admin-sidebar-animate`, which
`ngAfterViewInit` enables only after the first paint so the initial SSR->CSR
resolution never animates.

Adaptations to the v9 base (the source commit targets the 7.x root component):

* `.outer-wrapper` on 9-base already carries `[ngClass]="browserOsClasses…"`, a
  vanilla-9 feature 7.x does not have. Angular allows one `[ngClass]` per
  element, and overwriting it would silently drop `browser-firefox` /
  `browser-firefox-windows`, which `_custom_variables.scss` uses. The two are
  therefore merged into a single `outerWrapperClasses$` stream instead of
  pasting the source's `[ngClass]="'ds-admin-sidebar-' + (…)"`. A unit test
  guards the merge.
* the sidebar element is `<ds-admin-sidebar>` on v9, not
  `<ds-themed-admin-sidebar>`; that context line is left as it is.
* the source's trailing-comma reformat of the `windowService` constructor
  parameter is already present on 9-base, and `AfterViewInit` joins a
  multi-line `@angular/core` import, so those two hunks are no-ops here.

`slideSidebarPadding` itself is deliberately left in
`src/app/shared/animations/slide.ts`: after this change it still has one
consumer, the `custom` theme's `file-section.component.ts`.

Two unit tests are added beyond the source commit, which ships none. The v9
`root.component.spec.ts` had a single `it('should create')` that cannot detect
this regression. Both new tests were proven load-bearing with negative controls
(see the PR description).

Source: 1321b8a (dtq-dev PR #1350)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milanmajchrak

Copy link
Copy Markdown
Collaborator Author

Local gates (Windows, Node 20.19.0, worktree C:/wt9fe3 at 7fa1bb6526)

Run in build.yml order.

$ npm run build:lint
> rimraf --glob 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json
(exit 0)

$ npm run lint:nobuild -- --quiet
Linting "dspace-angular"...
All files pass linting.

$ npx madge --exclude "(bitstream|bundle|collection|config-submission-form|eperson|item|version)\.model\.ts$" --circular --extensions ts ./
Processed 3359 files (54.3s) (165 warnings)
✔ No circular dependency found!
# note: `npm run check-circ-deps` itself cannot run on Windows — cmd.exe does not parse the script's
# single-quoted regex ("'bundle' is not recognized as an internal or external command"). Same regex,
# double-quoted, above. Unaffected on the CI Linux runner.

$ npm run test:headless -- --include='src/app/root/root.component.spec.ts' --code-coverage=false
  RootComponent
    ✔ should create
    ✔ should enable the gutter transition only after the first paint
    ✔ should emit the hidden gutter state when the admin sidebar is not visible
TOTAL: 3 SUCCESS

$ NODE_OPTIONS=--max-old-space-size=4096 npm run build:prod
(exit 0; dist/browser + dist/server produced; 0 matches for 'error TS|Module not found|NG[0-9]{4}|Error:')
# the only diagnostics are 19 pre-existing "src/themes/custom/… is part of the TypeScript compilation
# but it's unused" warnings — the `custom` theme is not the active one.

npm run test:lint:nobuild reports 169 specs, 8 failures on this machine. Pre-existing, not this
PR
— the identical run on a stashed (clean) tree gives the same 8, all in lint/test/** fixtures
(themed-decorators ×4, themed-component-usages ×3, theme-support ×1), none of which touches
src/app/root. CI's tests (20.x)/(22.x) runs the same step and is green on the base commit
b0c48c578e.

Server-side render check — the actual point of the change

Built bundle served locally, REST proxied to dev-6:8603:

$ curl -s -H 'Host: localhost:4000' http://127.0.0.1:4713/home \
    | grep -o '<div[^>]*outer-wrapper[^>]*>' | head -1
<div _ngcontent-dspace-angular-c2400798145="" class="outer-wrapper ds-admin-sidebar-hidden">

versus the same element on the currently deployed (un-ported) build:

$ curl -s http://dev-6.pc:8603/repository/home | grep -o '<div[^>]*outer-wrapper[^>]*>' | head -1
<div _ngcontent-dspace-angular-c4194216939="" class="outer-wrapper ng-tns-c4194216939-0 ng-trigger ng-trigger-slideSidebarPadding" style="padding-left: 0;">

The gutter class is now server-rendered, the animation marker is gone, and there is no inline
style="padding-left: 0;" for the browser to override on hydration.

Two gotchas that cost time and are worth writing down for whoever re-runs this:

  • SSR only runs when the request's Host header matches ui.baseUrl (default-app-config.ts:50).
    Without -H 'Host: …' the server answers 200 with the 1211-byte CSR shell (<ds-app></ds-app>
    empty), which reads exactly like "the class is missing" — a false negative.
  • On Git Bash, DSPACE_REST_NAMESPACE=/repository/server is path-mangled into
    C:/Program Files/Git/repository/server unless the command is prefixed with MSYS_NO_PATHCONV=1.

Diff scope

$ git diff origin/dtq-dev-9-base...7fa1bb6526 --numstat
3	4	src/app/root/root.component.html
28	0	src/app/root/root.component.scss
47	0	src/app/root/root.component.spec.ts
54	3	src/app/root/root.component.ts
0	2	src/themes/custom/app/root/root.component.ts

No i18n file is touched, so sync-i18n is not applicable (and was not run — the script currently
rewrites 33 locale files regardless of its -t/-i/-o/-d switches).

@milanmajchrak

Copy link
Copy Markdown
Collaborator Author

CI is green on 7fa1bb6526: tests (20.x) and tests (22.x) pass on both the push and the pull_request run, including every Verify SSR step and the 301/403/404/500 checks; both docker-build jobs pass. No non-success step in either job.

The remaining gate is the live one (card AC-FE-13-3 / manual scenario UNIVERSAL-028), which needs this merged and deployed to 8603.

@milanmajchrak
milanmajchrak merged commit 8ac588e into dtq-dev-9-base Sep 10, 2026
9 checks passed
@milanmajchrak
milanmajchrak deleted the ufal/port-1350-9-base branch September 10, 2026 15:39
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