From b68eb6173ac67de6e5d4bce98c5000884f2bdc6a Mon Sep 17 00:00:00 2001 From: Kirill Kolesnikov Date: Sat, 5 Sep 2026 15:45:41 +0700 Subject: [PATCH] Restore the five layouts' visual and behavioral fidelity to VueForge's fd793696 You said the components now roughly match fd793696, but the layouts still didn't. Checked directly rather than by feel: at that exact commit, packages/layouts held VfAdminLayout, VfAppShell, VfDocumentLayout, VfAdminShell, and VfSetupLayout -- the direct ancestors of today's five, sharing the same style-entry file names. Diffed the CSS line by line: admin-layout 260 lines there versus 140 here, app-shell 362 vs 83, admin-shell 162 vs 63, document-layout 286 vs 69, setup-layout 293 vs 54. Not typos -- a deliberate simplification during the multiplatform rewrite that never got its sophistication back. Restored what was actually lost, adapted to the architecture that rewrite correctly introduced (state as data-cm-* attributes the Razor adapter and a shared runtime controller both read, not Vue-only classes and scoped-slot-exposed functions) rather than reverting to VueForge's own, framework-only implementation: - Admin layout: sidebar and header are fixed to the viewport again, not laid out in a static grid. A collapsed sidebar opens on hover or keyboard focus without leaving the collapsed state -- restored as plain :is(:hover, :has(:focus-visible)) CSS, since that was already how VueForge's version did it too; no JS ever drove it. Responsiveness is a real @container query against the layout's own inline size (matching VueForge's --vf-bp-lg-down, which resolves to exactly 1024px, same as this design system's own --cm-breakpoint-lg) rather than the viewport, so an admin layout embedded in a narrower pane degrades the same way a narrow window would. - App shell: sidebar and aside are sticky with their own capped height and scrollbar again, so content taller than the viewport no longer pushes past the bottom edge. The aside now collapses before the sidebar does on a narrowing window (two breakpoints, not one), matching the original. - Admin shell: the workspace is an elevated card inset within the shell again -- rounded top corners, its own border, margin on three sides -- rather than flush with the sidebar. The topbar is sticky and deliberately dark in both themes, via the same background-inverse/text-inverse token pair tooltip.css and badge.css already use for exactly this contrast. - Document layout: sidebar and aside are sticky with a capped, scrollable height again, the page gets a two-sided border once there's room to see it, and the aside now has its own breakpoint before the sidebar does. - Setup layout: given an aside, it now sits beside the form once there's room, divided by one border, instead of always stacking above it -- the wizard-with-illustration layout VueForge had, reimplemented with CSS grid rather than reverting to VueForge's own combinatorial one (this version only handles the header+main+aside split, not every brand/toolbar/aside permutation the original enumerated, which is a real, deliberate scope cut, not an oversight). Found and fixed a real bug in the showcase itself while making all this checkable: the demo frame had no explicit width, so at its narrow default it always tripped every layout's own narrow-window behavior, and its border-radius/overflow:hidden gave position: fixed nothing to be relative to -- those elements escaped the frame and pinned themselves to the real browser window. Added `contain: layout paint` to establish the frame as their containing block, and widened the showcase column enough (76rem) to clear every restored breakpoint, so the demos show their intended desktop form by default and only degrade to the mobile one when actually narrow -- correctly, not by accident. Verified every claim live over the Chrome DevTools protocol against the running dev server, not by reading the CSS and assuming: measured the escaped-fixed-position bug's exact pixel offset before the frame fix and confirmed containment after; drove the admin layout's collapse toggle and confirmed 288px -> 76px; dispatched a real mouse-move onto the collapsed sidebar and confirmed hover-expand to 288px while data-cm-sidebar-collapsed stayed "true"; emulated a 390px viewport and confirmed the mobile drawer's off-canvas start state, its post-toggle data-cm-mobile-sidebar-open and backdrop opacity, and its screenshot; screenshotted every layout in both themes, including the admin shell topbar contrast, which was wrong (near-black text on a near-black bar) before the background-inverse/text-inverse fix and correct after, in both themes. npm run verify passes in full. --- examples/ui-showcase/src/App.vue | 16 +- examples/ui-showcase/src/showcase.css | 13 +- packages/css/src/components/admin-layout.css | 174 +++++++++++++++--- packages/css/src/components/admin-shell.css | 39 +++- packages/css/src/components/app-shell.css | 22 +++ .../css/src/components/document-layout.css | 22 +++ packages/css/src/components/setup-layout.css | 64 +++++++ .../css/css/components/admin-layout.css | 174 +++++++++++++++--- .../assets/css/css/components/admin-shell.css | 39 +++- .../assets/css/css/components/app-shell.css | 22 +++ .../css/css/components/document-layout.css | 22 +++ .../css/css/components/setup-layout.css | 64 +++++++ packages/razor/resources/assets/manifest.json | 10 +- 13 files changed, 602 insertions(+), 79 deletions(-) diff --git a/examples/ui-showcase/src/App.vue b/examples/ui-showcase/src/App.vue index cf66d7b8..46ac03b0 100644 --- a/examples/ui-showcase/src/App.vue +++ b/examples/ui-showcase/src/App.vue @@ -653,13 +653,19 @@ const tableOfContentsItems = [

Admin layout

Regions plus a collapsible sidebar. State lives on the root as data-cm-*, so the Razor adapter and - the runtime controller read the same thing Vue binds. + the runtime controller read the same thing Vue binds. A collapsed sidebar still opens on hover or keyboard + focus, without leaving the collapsed state.

- + Layout content
@@ -679,9 +685,13 @@ const tableOfContentsItems = [

Setup layout

-

A single-task page for onboarding steps.

+

+ A single-task page for onboarding steps. Given an aside, it sits beside the form rather than above it once + there's room -- aside-position picks which side. +

+ Workspace name diff --git a/examples/ui-showcase/src/showcase.css b/examples/ui-showcase/src/showcase.css index e1d2e0e9..8f861d10 100644 --- a/examples/ui-showcase/src/showcase.css +++ b/examples/ui-showcase/src/showcase.css @@ -7,8 +7,12 @@ system, so the fallback colors (#fff/#111) always won regardless of theme. */ .ui-showcase { + /* Wide enough that the layout demos (CmAdminLayout, CmAppShell, ...) clear their own 64rem + container-query breakpoint and render their desktop form -- narrower than that and their + container queries correctly, not incidentally, switch them to the mobile drawer they'd also + show in a real narrow window. */ margin: 0 auto; - max-width: 60rem; + max-width: 76rem; padding: 2rem 1.5rem 4rem; } @@ -45,6 +49,13 @@ resize: vertical; border: 1px solid var(--cm-color-border-subtle, #e5e5e5); border-radius: var(--cm-radius-surface, 8px); + + /* Some layouts (CmAdminLayout, CmAdminShell, ...) fix their sidebar/header to the viewport, which + is correct once they're a real page -- but "viewport" for position: fixed is the nearest + ancestor that establishes a containing block, and nothing here did, so those elements escaped + this frame entirely and pinned themselves to the actual browser window instead. `contain: layout + paint` makes this box that ancestor, the same way an iframe's document would be one on its own. */ + contain: layout paint; } .ui-showcase__inline { diff --git a/packages/css/src/components/admin-layout.css b/packages/css/src/components/admin-layout.css index cae94cad..eff8e6f9 100644 --- a/packages/css/src/components/admin-layout.css +++ b/packages/css/src/components/admin-layout.css @@ -2,26 +2,66 @@ * The layout reads its own state from attributes on the root rather than from classes a framework * toggles. That is what lets one stylesheet serve both adapters and a server-rendered page that has * not run JavaScript yet. + * + * The sidebar and header are fixed to the viewport rather than scrolling with the page -- only the + * content area scrolls. Responsiveness is a container query against the layout's own inline size, + * not the viewport: an admin layout embedded in a narrower pane (a preview frame, a split view) + * collapses to its mobile presentation on its own width, the same way it would in a full window. */ .cm-admin-layout { - display: grid; - grid-template-columns: auto 1fr; + display: block; min-block-size: 100dvb; + container: cm-admin-layout / inline-size; + background: var(--cm-color-background-canvas); } .cm-admin-layout__aside { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; flex-direction: column; - gap: var(--cm-space-3); - inline-size: 16rem; - padding: var(--cm-space-3); - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + inline-size: 18rem; + overflow: hidden; background: var(--cm-color-background-surface); - transition: inline-size var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + transition: inline-size var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); } .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { - inline-size: 4rem; + inline-size: 4.75rem; +} + +/* + * A collapsed sidebar still opens on hover or keyboard focus, wide enough to read again, without + * changing the collapsed state itself -- that state is for the layout at rest, not for whether you + * can currently see it. + */ +.cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + inline-size: 18rem; +} + +.cm-admin-layout__brand { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--cm-space-2); + block-size: 4rem; + padding: var(--cm-space-2) var(--cm-space-3); + overflow: hidden; +} + +.cm-admin-layout__aside:has(.cm-admin-layout__aside-content) .cm-admin-layout__brand { + border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); +} + +.cm-admin-layout__aside-content { + min-block-size: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: var(--cm-space-3); } .cm-admin-layout__main { @@ -30,11 +70,25 @@ min-inline-size: 0; } +.cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 18rem; + transition: margin-inline-start var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); +} + +.cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 4.75rem; +} + .cm-admin-layout__header { + position: sticky; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 1); display: flex; align-items: center; gap: var(--cm-space-2); + block-size: 4rem; padding: var(--cm-space-2) var(--cm-space-3); + background: var(--cm-color-background-surface); border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -101,44 +155,104 @@ } /* - * Below the breakpoint the sidebar becomes a drawer: off-canvas by default, slid in when the root - * says the drawer is open, with a backdrop that closes it. + * Below the container's own inline-size threshold the sidebar becomes a drawer: off-canvas by + * default, slid in when the root says the drawer is open, with a backdrop that fades in behind it + * and closes it on click. The fade is delayed on the way out and immediate on the way in, so the + * drawer never appears to sit under a backdrop that hasn't shown up yet. */ -@media (width < 48rem) { - .cm-admin-layout { - grid-template-columns: 1fr; - } - - .cm-admin-layout__mobile-toggle, - .cm-admin-layout__mobile-brand { - display: block; - } - +@container cm-admin-layout (width < 64rem) { .cm-admin-layout__aside { - position: fixed; - z-index: 2; - inset-block: 0; - inset-inline-start: 0; - inline-size: 16rem; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + inline-size: min(18rem, 85cqi); + visibility: hidden; translate: -100% 0; - transition: translate var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + [dir='rtl'] .cm-admin-layout__aside { + translate: 100% 0; } .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__aside { + visibility: visible; translate: 0 0; + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; } - .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { + inline-size: min(18rem, 85cqi); + } + + .cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main, + .cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 0; + } + + .cm-admin-layout__header { + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .cm-admin-layout__mobile-toggle { + display: flex; + justify-self: start; + } + + .cm-admin-layout__mobile-brand { + display: flex; + min-inline-size: 0; + align-items: center; + justify-content: center; + grid-column: 2; + } + + .cm-admin-layout__header-content { + inline-size: auto; + display: flex; + min-inline-size: 0; + justify-self: end; + grid-column: 3; + } + + .cm-admin-layout__mobile-backdrop { position: fixed; - z-index: 1; - display: block; inset: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + display: block; background: var(--cm-color-background-backdrop); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; + } + + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 4); + inline-size: min(18rem, 85cqi); } } @media (prefers-reduced-motion: reduce) { - .cm-admin-layout__aside { + .cm-admin-layout__aside, + .cm-admin-layout__main, + .cm-admin-layout__mobile-backdrop { transition: none; } } diff --git a/packages/css/src/components/admin-shell.css b/packages/css/src/components/admin-shell.css index eb1f1b91..666a9229 100644 --- a/packages/css/src/components/admin-shell.css +++ b/packages/css/src/components/admin-shell.css @@ -1,16 +1,24 @@ -/* A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. */ +/* + * A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. The + * workspace is a distinct, elevated card inset within the shell rather than a plain flow region -- + * that's what separates it visually from the sidebar sitting flush beside it. + */ .cm-admin-shell { display: flex; flex-direction: column; min-block-size: 100dvb; + background: var(--cm-color-background-inverse); } .cm-admin-shell__topbar { + position: sticky; + inset-block-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; align-items: center; gap: var(--cm-space-3); padding: var(--cm-space-2) var(--cm-space-3); - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + color: var(--cm-color-text-inverse); } .cm-admin-shell__header { @@ -19,17 +27,24 @@ } .cm-admin-shell__body { + position: relative; display: flex; flex: 1 1 auto; min-block-size: 0; + background: var(--cm-color-background-canvas); + border-start-start-radius: var(--cm-radius-surface); + border-start-end-radius: var(--cm-radius-surface); } .cm-admin-shell__sidebar { inline-size: 16rem; - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); } .cm-admin-shell__sidebar-content { + position: sticky; + inset-block-start: 0; + max-block-size: 100dvb; + overflow-y: auto; padding: var(--cm-space-3); } @@ -38,6 +53,12 @@ flex: 1 1 auto; flex-direction: column; min-inline-size: 0; + margin-block: var(--cm-space-3); + margin-inline-end: var(--cm-space-3); + overflow: hidden; + background: var(--cm-color-background-surface); + border: var(--cm-border-width) solid var(--cm-color-border-default); + border-radius: var(--cm-radius-surface); } .cm-admin-shell__content { @@ -46,6 +67,7 @@ } .cm-admin-shell__footer { + margin-block-start: auto; padding: var(--cm-space-2) var(--cm-space-3); border-block-start: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -57,7 +79,14 @@ .cm-admin-shell__sidebar { inline-size: auto; - border-inline-end: 0; - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-admin-shell__sidebar-content { + position: static; + max-block-size: none; + } + + .cm-admin-shell__workspace { + margin-inline-start: var(--cm-space-3); } } diff --git a/packages/css/src/components/app-shell.css b/packages/css/src/components/app-shell.css index 058f2d3a..b03cbf4d 100644 --- a/packages/css/src/components/app-shell.css +++ b/packages/css/src/components/app-shell.css @@ -49,6 +49,11 @@ .cm-app-shell__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + + /* Sticky alone still lets a sidebar taller than the viewport push past its bottom edge; the cap + plus its own scrollbar are what let it stick without doing that. */ + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -70,6 +75,21 @@ grid-template-columns: minmax(0, 1fr); } +/* + * Two stages, not one: the aside is the first casualty of a narrowing window (nice-to-have context, + * not primary navigation), so it goes before the sidebar does. Below 64em there's no room for a + * three- or even two-column grid at all, and both go static and stack with the content. + */ +@media (width < 80em) { + .cm-app-shell--sidebar-content-aside .cm-app-shell__body { + grid-template-columns: minmax(0, 16rem) minmax(0, 1fr); + } + + .cm-app-shell--sidebar-content-aside .cm-app-shell__aside { + display: none; + } +} + @media (width < 64em) { .cm-app-shell--sidebar-content .cm-app-shell__body, .cm-app-shell--sidebar-content-aside .cm-app-shell__body { @@ -79,5 +99,7 @@ .cm-app-shell__sidebar, .cm-app-shell__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/css/src/components/document-layout.css b/packages/css/src/components/document-layout.css index 51fc1173..30b261a3 100644 --- a/packages/css/src/components/document-layout.css +++ b/packages/css/src/components/document-layout.css @@ -10,6 +10,14 @@ min-block-size: 100vh; } +/* A framed page, once there's room to see the frame -- borderless below that would only be a sliver + of border on either side, not an actual margin. */ +@media (width > 48rem) { + .cm-document-layout { + border-inline: var(--cm-border-width) solid var(--cm-color-border-subtle); + } +} + .cm-document-layout__header { position: sticky; inset-block-start: 0; @@ -45,6 +53,8 @@ .cm-document-layout__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -56,6 +66,16 @@ border-block-start: var(--cm-border-width) solid var(--cm-color-border-subtle); } +@media (width < 80em) { + .cm-document-layout--sidebar-content-aside .cm-document-layout__content { + grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); + } + + .cm-document-layout--sidebar-content-aside .cm-document-layout__aside { + display: none; + } +} + @media (width < 64em) { .cm-document-layout--sidebar-content .cm-document-layout__content, .cm-document-layout--sidebar-content-aside .cm-document-layout__content { @@ -65,5 +85,7 @@ .cm-document-layout__sidebar, .cm-document-layout__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/css/src/components/setup-layout.css b/packages/css/src/components/setup-layout.css index 22449153..b448805d 100644 --- a/packages/css/src/components/setup-layout.css +++ b/packages/css/src/components/setup-layout.css @@ -52,3 +52,67 @@ .cm-setup-layout--aside-left .cm-setup-layout__aside { order: -1; } + +/* + * An aside stacks above the form by default -- the same single centered card either way. Once + * there's room, a wizard with an aside (an illustration, a summary) earns a second column instead: + * the two sit side by side, divided by one border, rather than one queued above the other. + */ +@media (width > 48rem) { + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + display: grid; + grid-template-columns: minmax(0, 1fr) 18rem; + grid-template-areas: + 'brand aside' + 'toolbar aside' + 'header aside' + 'main aside'; + align-items: start; + gap: 0; + max-inline-size: 56rem; + padding: 0; + overflow: hidden; + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + grid-template-columns: 18rem minmax(0, 1fr); + grid-template-areas: + 'aside brand' + 'aside toolbar' + 'aside header' + 'aside main'; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__brand { + grid-area: brand; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__toolbar { + grid-area: toolbar; + padding: var(--cm-space-3) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__header { + grid-area: header; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__main { + grid-area: main; + padding: var(--cm-space-4); + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + grid-area: aside; + align-self: stretch; + padding: var(--cm-space-4); + border-inline-start: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + order: 0; + border-inline-start: 0; + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + } +} diff --git a/packages/razor/resources/assets/css/css/components/admin-layout.css b/packages/razor/resources/assets/css/css/components/admin-layout.css index cae94cad..eff8e6f9 100644 --- a/packages/razor/resources/assets/css/css/components/admin-layout.css +++ b/packages/razor/resources/assets/css/css/components/admin-layout.css @@ -2,26 +2,66 @@ * The layout reads its own state from attributes on the root rather than from classes a framework * toggles. That is what lets one stylesheet serve both adapters and a server-rendered page that has * not run JavaScript yet. + * + * The sidebar and header are fixed to the viewport rather than scrolling with the page -- only the + * content area scrolls. Responsiveness is a container query against the layout's own inline size, + * not the viewport: an admin layout embedded in a narrower pane (a preview frame, a split view) + * collapses to its mobile presentation on its own width, the same way it would in a full window. */ .cm-admin-layout { - display: grid; - grid-template-columns: auto 1fr; + display: block; min-block-size: 100dvb; + container: cm-admin-layout / inline-size; + background: var(--cm-color-background-canvas); } .cm-admin-layout__aside { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; flex-direction: column; - gap: var(--cm-space-3); - inline-size: 16rem; - padding: var(--cm-space-3); - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + inline-size: 18rem; + overflow: hidden; background: var(--cm-color-background-surface); - transition: inline-size var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + transition: inline-size var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); } .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { - inline-size: 4rem; + inline-size: 4.75rem; +} + +/* + * A collapsed sidebar still opens on hover or keyboard focus, wide enough to read again, without + * changing the collapsed state itself -- that state is for the layout at rest, not for whether you + * can currently see it. + */ +.cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + inline-size: 18rem; +} + +.cm-admin-layout__brand { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--cm-space-2); + block-size: 4rem; + padding: var(--cm-space-2) var(--cm-space-3); + overflow: hidden; +} + +.cm-admin-layout__aside:has(.cm-admin-layout__aside-content) .cm-admin-layout__brand { + border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); +} + +.cm-admin-layout__aside-content { + min-block-size: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: var(--cm-space-3); } .cm-admin-layout__main { @@ -30,11 +70,25 @@ min-inline-size: 0; } +.cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 18rem; + transition: margin-inline-start var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); +} + +.cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 4.75rem; +} + .cm-admin-layout__header { + position: sticky; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 1); display: flex; align-items: center; gap: var(--cm-space-2); + block-size: 4rem; padding: var(--cm-space-2) var(--cm-space-3); + background: var(--cm-color-background-surface); border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -101,44 +155,104 @@ } /* - * Below the breakpoint the sidebar becomes a drawer: off-canvas by default, slid in when the root - * says the drawer is open, with a backdrop that closes it. + * Below the container's own inline-size threshold the sidebar becomes a drawer: off-canvas by + * default, slid in when the root says the drawer is open, with a backdrop that fades in behind it + * and closes it on click. The fade is delayed on the way out and immediate on the way in, so the + * drawer never appears to sit under a backdrop that hasn't shown up yet. */ -@media (width < 48rem) { - .cm-admin-layout { - grid-template-columns: 1fr; - } - - .cm-admin-layout__mobile-toggle, - .cm-admin-layout__mobile-brand { - display: block; - } - +@container cm-admin-layout (width < 64rem) { .cm-admin-layout__aside { - position: fixed; - z-index: 2; - inset-block: 0; - inset-inline-start: 0; - inline-size: 16rem; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + inline-size: min(18rem, 85cqi); + visibility: hidden; translate: -100% 0; - transition: translate var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + [dir='rtl'] .cm-admin-layout__aside { + translate: 100% 0; } .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__aside { + visibility: visible; translate: 0 0; + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; } - .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { + inline-size: min(18rem, 85cqi); + } + + .cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main, + .cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 0; + } + + .cm-admin-layout__header { + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .cm-admin-layout__mobile-toggle { + display: flex; + justify-self: start; + } + + .cm-admin-layout__mobile-brand { + display: flex; + min-inline-size: 0; + align-items: center; + justify-content: center; + grid-column: 2; + } + + .cm-admin-layout__header-content { + inline-size: auto; + display: flex; + min-inline-size: 0; + justify-self: end; + grid-column: 3; + } + + .cm-admin-layout__mobile-backdrop { position: fixed; - z-index: 1; - display: block; inset: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + display: block; background: var(--cm-color-background-backdrop); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; + } + + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 4); + inline-size: min(18rem, 85cqi); } } @media (prefers-reduced-motion: reduce) { - .cm-admin-layout__aside { + .cm-admin-layout__aside, + .cm-admin-layout__main, + .cm-admin-layout__mobile-backdrop { transition: none; } } diff --git a/packages/razor/resources/assets/css/css/components/admin-shell.css b/packages/razor/resources/assets/css/css/components/admin-shell.css index eb1f1b91..666a9229 100644 --- a/packages/razor/resources/assets/css/css/components/admin-shell.css +++ b/packages/razor/resources/assets/css/css/components/admin-shell.css @@ -1,16 +1,24 @@ -/* A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. */ +/* + * A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. The + * workspace is a distinct, elevated card inset within the shell rather than a plain flow region -- + * that's what separates it visually from the sidebar sitting flush beside it. + */ .cm-admin-shell { display: flex; flex-direction: column; min-block-size: 100dvb; + background: var(--cm-color-background-inverse); } .cm-admin-shell__topbar { + position: sticky; + inset-block-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; align-items: center; gap: var(--cm-space-3); padding: var(--cm-space-2) var(--cm-space-3); - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + color: var(--cm-color-text-inverse); } .cm-admin-shell__header { @@ -19,17 +27,24 @@ } .cm-admin-shell__body { + position: relative; display: flex; flex: 1 1 auto; min-block-size: 0; + background: var(--cm-color-background-canvas); + border-start-start-radius: var(--cm-radius-surface); + border-start-end-radius: var(--cm-radius-surface); } .cm-admin-shell__sidebar { inline-size: 16rem; - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); } .cm-admin-shell__sidebar-content { + position: sticky; + inset-block-start: 0; + max-block-size: 100dvb; + overflow-y: auto; padding: var(--cm-space-3); } @@ -38,6 +53,12 @@ flex: 1 1 auto; flex-direction: column; min-inline-size: 0; + margin-block: var(--cm-space-3); + margin-inline-end: var(--cm-space-3); + overflow: hidden; + background: var(--cm-color-background-surface); + border: var(--cm-border-width) solid var(--cm-color-border-default); + border-radius: var(--cm-radius-surface); } .cm-admin-shell__content { @@ -46,6 +67,7 @@ } .cm-admin-shell__footer { + margin-block-start: auto; padding: var(--cm-space-2) var(--cm-space-3); border-block-start: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -57,7 +79,14 @@ .cm-admin-shell__sidebar { inline-size: auto; - border-inline-end: 0; - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-admin-shell__sidebar-content { + position: static; + max-block-size: none; + } + + .cm-admin-shell__workspace { + margin-inline-start: var(--cm-space-3); } } diff --git a/packages/razor/resources/assets/css/css/components/app-shell.css b/packages/razor/resources/assets/css/css/components/app-shell.css index 058f2d3a..b03cbf4d 100644 --- a/packages/razor/resources/assets/css/css/components/app-shell.css +++ b/packages/razor/resources/assets/css/css/components/app-shell.css @@ -49,6 +49,11 @@ .cm-app-shell__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + + /* Sticky alone still lets a sidebar taller than the viewport push past its bottom edge; the cap + plus its own scrollbar are what let it stick without doing that. */ + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -70,6 +75,21 @@ grid-template-columns: minmax(0, 1fr); } +/* + * Two stages, not one: the aside is the first casualty of a narrowing window (nice-to-have context, + * not primary navigation), so it goes before the sidebar does. Below 64em there's no room for a + * three- or even two-column grid at all, and both go static and stack with the content. + */ +@media (width < 80em) { + .cm-app-shell--sidebar-content-aside .cm-app-shell__body { + grid-template-columns: minmax(0, 16rem) minmax(0, 1fr); + } + + .cm-app-shell--sidebar-content-aside .cm-app-shell__aside { + display: none; + } +} + @media (width < 64em) { .cm-app-shell--sidebar-content .cm-app-shell__body, .cm-app-shell--sidebar-content-aside .cm-app-shell__body { @@ -79,5 +99,7 @@ .cm-app-shell__sidebar, .cm-app-shell__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/razor/resources/assets/css/css/components/document-layout.css b/packages/razor/resources/assets/css/css/components/document-layout.css index 51fc1173..30b261a3 100644 --- a/packages/razor/resources/assets/css/css/components/document-layout.css +++ b/packages/razor/resources/assets/css/css/components/document-layout.css @@ -10,6 +10,14 @@ min-block-size: 100vh; } +/* A framed page, once there's room to see the frame -- borderless below that would only be a sliver + of border on either side, not an actual margin. */ +@media (width > 48rem) { + .cm-document-layout { + border-inline: var(--cm-border-width) solid var(--cm-color-border-subtle); + } +} + .cm-document-layout__header { position: sticky; inset-block-start: 0; @@ -45,6 +53,8 @@ .cm-document-layout__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -56,6 +66,16 @@ border-block-start: var(--cm-border-width) solid var(--cm-color-border-subtle); } +@media (width < 80em) { + .cm-document-layout--sidebar-content-aside .cm-document-layout__content { + grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); + } + + .cm-document-layout--sidebar-content-aside .cm-document-layout__aside { + display: none; + } +} + @media (width < 64em) { .cm-document-layout--sidebar-content .cm-document-layout__content, .cm-document-layout--sidebar-content-aside .cm-document-layout__content { @@ -65,5 +85,7 @@ .cm-document-layout__sidebar, .cm-document-layout__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/razor/resources/assets/css/css/components/setup-layout.css b/packages/razor/resources/assets/css/css/components/setup-layout.css index 22449153..b448805d 100644 --- a/packages/razor/resources/assets/css/css/components/setup-layout.css +++ b/packages/razor/resources/assets/css/css/components/setup-layout.css @@ -52,3 +52,67 @@ .cm-setup-layout--aside-left .cm-setup-layout__aside { order: -1; } + +/* + * An aside stacks above the form by default -- the same single centered card either way. Once + * there's room, a wizard with an aside (an illustration, a summary) earns a second column instead: + * the two sit side by side, divided by one border, rather than one queued above the other. + */ +@media (width > 48rem) { + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + display: grid; + grid-template-columns: minmax(0, 1fr) 18rem; + grid-template-areas: + 'brand aside' + 'toolbar aside' + 'header aside' + 'main aside'; + align-items: start; + gap: 0; + max-inline-size: 56rem; + padding: 0; + overflow: hidden; + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + grid-template-columns: 18rem minmax(0, 1fr); + grid-template-areas: + 'aside brand' + 'aside toolbar' + 'aside header' + 'aside main'; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__brand { + grid-area: brand; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__toolbar { + grid-area: toolbar; + padding: var(--cm-space-3) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__header { + grid-area: header; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__main { + grid-area: main; + padding: var(--cm-space-4); + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + grid-area: aside; + align-self: stretch; + padding: var(--cm-space-4); + border-inline-start: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + order: 0; + border-inline-start: 0; + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + } +} diff --git a/packages/razor/resources/assets/manifest.json b/packages/razor/resources/assets/manifest.json index c0be8136..3e0c527c 100644 --- a/packages/razor/resources/assets/manifest.json +++ b/packages/razor/resources/assets/manifest.json @@ -114,7 +114,7 @@ "css:components/admin-layout.css": { "path": "css/css/components/admin-layout.css", "mediaType": "text/css", - "sha256": "954a8b362b15c76bbc22e9910e0e44e54aabbdf15a53aa0a0f8e9864ad8836c3", + "sha256": "bfd1ca47a9b86359a5f704b5d4afd8645cfd45ba324b8bb8c8723ea9aaa81691", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -124,7 +124,7 @@ "css:components/admin-shell.css": { "path": "css/css/components/admin-shell.css", "mediaType": "text/css", - "sha256": "59a0d6c65ad3a9c2194413e516b6d4019e1e819f04f0e429e5915917778da906", + "sha256": "18622f3fe0ac95fe7f559e4c360e35f645002aae2862cf4dab954a694537f239", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -144,7 +144,7 @@ "css:components/app-shell.css": { "path": "css/css/components/app-shell.css", "mediaType": "text/css", - "sha256": "9bfdaa294df92b1083ba748311b6062713b6f68f9c80f92d1ff2730b828db3b3", + "sha256": "02ef5a296b291b7facb43faff5ff936eeae1f90caab7333245128c5f77548c63", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -284,7 +284,7 @@ "css:components/document-layout.css": { "path": "css/css/components/document-layout.css", "mediaType": "text/css", - "sha256": "3354af03dcb0782e7090560b48c7095fc2c9f37bf44f024b8718ddc02c8d772c", + "sha256": "70622e2f555352d091da40cf06e9327bf9ecb8d4d4b2eba6e142204a8472180d", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -484,7 +484,7 @@ "css:components/setup-layout.css": { "path": "css/css/components/setup-layout.css", "mediaType": "text/css", - "sha256": "b237f6f9684cdb47ca6bb85f992ea81ae3aafe725cada03e969fc51a782ef548", + "sha256": "00ec6d285e68df94611b534542a73481852c26a270b23b41b82b28f4fc16e6e6", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0",