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 = [
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.
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.
+
+ Invite your team once you're set up -- they'll see this workspace immediately.
Workspace name
Continue
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",