@bagelink/vue 1.15.125 → 1.15.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.125",
4
+ "version": "1.15.129",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -88,7 +88,7 @@ defineExpose({
88
88
  height: min(90vh, 800px);
89
89
  margin: 1rem;
90
90
  border-radius: 12px;
91
- border: 1px solid var(--bgl-gray-20, #e0e0e0);
91
+ border: 1px solid var(--bgl-border-color, #e0e0e0);
92
92
  background: var(--bgl-box-bg, #fff);
93
93
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
94
94
  overflow: hidden;
@@ -192,7 +192,7 @@ inset-inline-end: var(--bgl-list-item-end-inset);
192
192
  z-index: 1;
193
193
  /* non-interactive meta lets clicks fall through to the row button… */
194
194
  pointer-events: none;
195
- padding-inline-end: 1rem;
195
+ padding-inline-end: 0.25rem;
196
196
  }
197
197
  /* …interactive trailing controls stay clickable on top of the row button. */
198
198
  .list-item-fullrow .list-item-end :where(button, a, input, select, label, [role="button"], [tabindex]) {
@@ -7,7 +7,7 @@ defineProps<{ items: NavLink[] }>()
7
7
  </script>
8
8
 
9
9
  <template>
10
- <div class="flex space-between m_w-100vw overflow m_-ms-1 m_-me-1 m_px-1 m_pt-025 m_pb-05 bglTopMenu">
10
+ <div class="flex space-between m_w-100vw overflow-x overflow-y-hidden m_-ms-1 m_-me-1 m_px-1 m_pt-025 m_pb-0 bglTopMenu">
11
11
  <div class="flex gap-025 m_gap-05">
12
12
  <template v-for="item in items" :key="item.label">
13
13
  <Dropdown v-if="item.children" :value="resolveI18n(item.label)" iconEnd="keyboard_arrow_down">
@@ -26,6 +26,8 @@ const props = defineProps<{
26
26
  debug?: boolean
27
27
  label?: string
28
28
  height?: number | string
29
+ // Make the editor fill the full height of its parent container
30
+ fullHeight?: boolean
29
31
  basic?: boolean
30
32
  simple?: boolean
31
33
  placeholder?: string
@@ -2119,6 +2121,11 @@ const hasRTL = computed(() => RTL_REGEX.test(props.modelValue))
2119
2121
 
2120
2122
  // Computed property to handle height prop
2121
2123
  const editorHeight = computed(() => {
2124
+ // When fullHeight is enabled the editor stretches to fill its parent,
2125
+ // so we don't impose a fixed pixel height.
2126
+ if (props.fullHeight) {
2127
+ return '100%'
2128
+ }
2122
2129
  if (typeof props.height === 'number') {
2123
2130
  return `${props.height}px`
2124
2131
  } if (typeof props.height === 'string') {
@@ -3267,7 +3274,7 @@ const imgTransform = computed({
3267
3274
 
3268
3275
  <template>
3269
3276
  <div
3270
- class="bagel-input" :class="[shellClass, { 'has-value': hasEditorValue }]" :style="shellStyle"
3277
+ class="bagel-input" :class="[shellClass, { 'has-value': hasEditorValue, 'rich-text-full-height': fullHeight }]" :style="shellStyle"
3271
3278
  v-bind="attrs"
3272
3279
  >
3273
3280
  <label v-if="label">{{ label }}</label>
@@ -3278,6 +3285,7 @@ const imgTransform = computed({
3278
3285
  'rich-text-editor pt-05 px-05 pb-075': !basic,
3279
3286
  'rich-text-editor--basic': basic,
3280
3287
  'fullscreen-mode': editor.state.isFullscreen,
3288
+ 'full-height': fullHeight && !editor.state.isFullscreen,
3281
3289
  },
3282
3290
  ]"
3283
3291
  >
@@ -3867,6 +3875,38 @@ white-space: pre-wrap;
3867
3875
  overflow-x: auto;
3868
3876
  }
3869
3877
 
3878
+ /* Full-height mode: the editor stretches to fill its parent container.
3879
+ The whole chain (input shell → editor → container → content-area → iframe)
3880
+ must be a flex column so the iframe can grow to occupy the remaining space. */
3881
+ .rich-text-full-height {
3882
+ display: flex;
3883
+ flex-direction: column;
3884
+ height: 100%;
3885
+ min-height: 0;
3886
+ }
3887
+
3888
+ .rich-text-full-height > .full-height {
3889
+ flex: 1;
3890
+ }
3891
+
3892
+ .full-height {
3893
+ display: flex;
3894
+ flex-direction: column;
3895
+ height: 100%;
3896
+ min-height: 0;
3897
+ }
3898
+
3899
+ .full-height .editor-container {
3900
+ flex: 1;
3901
+ min-height: 0;
3902
+ }
3903
+
3904
+ .full-height .content-area {
3905
+ height: 100%;
3906
+ min-height: 0;
3907
+ overflow-y: auto;
3908
+ }
3909
+
3870
3910
  .fullscreen-mode {
3871
3911
  position: fixed;
3872
3912
  top: 0;
@@ -1,20 +1,85 @@
1
1
  <script lang="ts" setup>
2
- import { computed } from 'vue'
2
+ import { computed, useSlots } from 'vue'
3
3
  import { Btn, PageTitle } from '@bagelink/vue'
4
4
  import { useAppLayout } from './appLayoutContext'
5
5
 
6
+ /** How the header is framed against the page body. */
7
+ type HeaderVariant = 'border' | 'card' | 'none'
8
+
6
9
  interface Props {
7
10
  title?: string
8
11
  /** Secondary line under the title (page description / context). */
9
12
  subtitle?: string
10
13
  showMenuButton?: boolean
11
14
  backTo?: string | Record<string, any>
15
+ /**
16
+ * Header framing:
17
+ * 'border' → bottom hairline under the header (default — legacy behaviour).
18
+ * 'card' → header sits as a rounded, elevated card above the body.
19
+ * 'none' → no border, no card (flush with the body).
20
+ */
21
+ variant?: HeaderVariant
22
+ /**
23
+ * Override the header framing on mobile (≤ breakpoint) only. Lets a header be,
24
+ * say, a `card` on desktop but flush (`none`) on a phone, where the extra chrome
25
+ * and margins feel heavy. Unset (default) = mobile inherits the desktop variant.
26
+ */
27
+ mobileVariant?: HeaderVariant
28
+ /**
29
+ * Shorthand for `variant="card"` — lets you write `<AppContent card>` instead
30
+ * of `<AppContent variant="card">`. Ignored when `variant` is passed explicitly.
31
+ */
32
+ card?: boolean
33
+ /**
34
+ * @deprecated Use `variant` instead. `border` maps to
35
+ * `variant="border"` (true, the old default) or `variant="none"` (false).
36
+ * Only applied when `variant` (and `card`) are not passed.
37
+ */
12
38
  border?: boolean
39
+ /**
40
+ * Fixed header height. Locked in on both desktop and mobile so the header's
41
+ * top-start corner (and everything in it — titles, inputs, buttons) sits at
42
+ * the exact same place on every page, regardless of content. Any CSS length.
43
+ */
44
+ headerHeight?: string
45
+ /**
46
+ * Lock the header to a single row of exactly `headerHeight` — content never
47
+ * wraps to a second line; anything that overflows is clipped. Off by default:
48
+ * the header keeps its fixed *minimum* height (stable start corner) but is
49
+ * free to grow downward into extra rows when content needs it (common on
50
+ * mobile). Turn this on only for headers you know must stay one exact line.
51
+ */
52
+ singleRow?: boolean
53
+ /**
54
+ * Cap the readable width of the page and center it on wide screens. Applies to
55
+ * BOTH the header and the body, so the title stays aligned above the content
56
+ * (no more per-view `w-1100px mx-auto`). Any CSS length, e.g. '1100px', '80rem'.
57
+ * Unset (default) = full-bleed. On narrow screens the content simply fills the
58
+ * available width (the cap only kicks in once the viewport is wider than it).
59
+ */
60
+ maxWidth?: string
61
+ /**
62
+ * Make the body's child fill the content area — the body becomes a flex column
63
+ * so its child takes the full width and the remaining height (`flex:1`) and owns
64
+ * its own scrolling. Saves repeating `h-100p w-100p overflow-hidden` on the
65
+ * wrapper of full-height views (tables, Kanban boards, chat, split panes). Off by
66
+ * default so short content (forms, empty states) still flows naturally at its own
67
+ * height instead of being stretched. Use only when the child should own the whole
68
+ * viewport region.
69
+ */
70
+ fill?: boolean
13
71
  }
14
72
 
15
- withDefaults(defineProps<Props>(), {
73
+ const props = withDefaults(defineProps<Props>(), {
16
74
  showMenuButton: true,
17
- border: true,
75
+ variant: undefined,
76
+ mobileVariant: undefined,
77
+ card: false,
78
+ border: undefined,
79
+ headerHeight: '60px',
80
+ singleRow: false,
81
+ maxWidth: undefined,
82
+ fill: false,
18
83
  })
19
84
 
20
85
  const { isOpen, toggleMenu, regions } = useAppLayout()
@@ -22,25 +87,61 @@ const { isOpen, toggleMenu, regions } = useAppLayout()
22
87
  // Only show the sidebar toggle when a sidebar actually exists (top/bottom-nav
23
88
  // apps have no start region → the button would darken an empty backdrop).
24
89
  const hasSidebar = computed(() => regions.value.start.present)
90
+
91
+ // Resolve the effective variant, in priority order: explicit `variant` wins; then
92
+ // the `card` shorthand; then the legacy `border` boolean; else the historical
93
+ // default of 'border'. (So `<AppContent card :border="false">` still yields a card.)
94
+ const resolvedVariant = computed<HeaderVariant>(() => {
95
+ if (props.variant) { return props.variant }
96
+ if (props.card) { return 'card' }
97
+ if (props.border !== undefined) { return props.border ? 'border' : 'none' }
98
+ return 'border'
99
+ })
100
+
101
+ // Mobile framing: an explicit `mobileVariant` overrides only below the breakpoint;
102
+ // otherwise mobile just inherits the resolved desktop variant. Both variant classes
103
+ // are always emitted (desktop + `--m-*`); the CSS media queries pick which applies.
104
+ const resolvedMobileVariant = computed<HeaderVariant>(() => props.mobileVariant ?? resolvedVariant.value)
105
+
106
+ // Lock the header to a single stable height on both breakpoints, and publish the
107
+ // optional content max-width. Both are inline vars so overrides stay trivial.
108
+ const headerVars = computed(() => ({
109
+ '--bgl-app-header-h': props.headerHeight,
110
+ ...(props.maxWidth ? { '--bgl-app-max-width': props.maxWidth } : {}),
111
+ }))
112
+
113
+ // Only center/cap when a maxWidth was actually given (keeps default full-bleed).
114
+ const isContained = computed(() => !!props.maxWidth)
115
+
116
+ // The center slot only renders (and reserves space) when actually provided —
117
+ // an empty middle should never force the header to wrap awkwardly.
118
+ const slots = useSlots()
119
+ const hasCenter = computed(() => !!slots['header-center'])
120
+
25
121
  </script>
26
122
 
27
123
  <template>
28
- <div class="app-content h-100p flex column">
29
- <!-- Header -->
124
+ <div class="app-content h-100p flex column" :class="{ 'app-content--contained': isContained }" :style="headerVars">
125
+ <!-- Header — fixed *minimum* height on every breakpoint so its start
126
+ corner sits in the exact same spot on every page. When content needs
127
+ more room (e.g. mobile) it wraps downward from that floor instead of
128
+ being clipped. Use `singleRow` to hard-lock to one exact line. -->
30
129
  <header
31
- class="app-header flex align-items-center space-between py-1 m_pt-025 m_pb-05 min-h60px w-100p m_flex-wrap"
32
- :class="{ 'border-bottom': border }"
130
+ class="app-header flex w-100p"
131
+ :class="[
132
+ `app-header--${resolvedVariant}`,
133
+ `app-header--m-${resolvedMobileVariant}`,
134
+ { 'app-header--single-row': singleRow },
135
+ ]"
33
136
  >
34
137
  <!-- Left Side -->
35
- <div class="flex align-items-center gap-col-075 m_flex-wrap m_pe-075">
138
+ <div class="app-header-side flex gap-col-075 min-w-0">
36
139
  <!-- Menu Toggle Button -->
37
- <div class="m_min-h60px flex">
38
- <Btn
39
- v-if="showMenuButton && hasSidebar" flat icon="dock_to_right" class="menuToggleButton "
40
- :aria-expanded="isOpen" aria-controls="bgl-app-sidebar" aria-label="Toggle sidebar"
41
- @click="toggleMenu"
42
- />
43
- </div>
140
+ <Btn
141
+ v-if="showMenuButton && hasSidebar" flat icon="dock_to_right" class="menuToggleButton"
142
+ :aria-expanded="isOpen" aria-controls="bgl-app-sidebar" aria-label="Toggle sidebar"
143
+ @click="toggleMenu"
144
+ />
44
145
 
45
146
  <!-- Back Button -->
46
147
  <Btn v-if="backTo" icon="arrow_back" thin :to="backTo" class="back-btn bg-bg m_-ms-05 m_-me-05 color-black" />
@@ -54,20 +155,27 @@ const hasSidebar = computed(() => regions.value.start.present)
54
155
  <slot name="header-left" />
55
156
  </div>
56
157
 
57
- <!-- Center Content -->
58
- <div class="flex align-items-center">
158
+ <!-- Center Content (only rendered when the slot is provided) -->
159
+ <div v-if="hasCenter" class="app-header-center flex min-w-0">
59
160
  <slot name="header-center" />
60
161
  </div>
61
162
 
62
163
  <!-- Right Side -->
63
- <div class="flex align-items-center gap-row-05 m_flex-grow-1 endNavTools">
164
+ <div class="app-header-side flex gap-row-05 endNavTools">
64
165
  <slot name="header-right" />
65
166
  </div>
66
167
  </header>
67
168
 
68
- <!-- Page Content -->
169
+ <!-- Page Content — the gap under the header follows the variant: a border
170
+ earns a bit of breathing room, but a borderless (none/card) header
171
+ sits flush so the body hugs it. -->
69
172
  <main
70
- class="pageContent flex-grow overflow pt-1 pb-05 w-100p m_py-05 m_scrollbar-gutter-auto"
173
+ class="pageContent flex-grow overflow pb-05 w-100p m_pb-05 m_scrollbar-gutter-auto"
174
+ :class="[
175
+ `pageContent--${resolvedVariant}`,
176
+ `pageContent--m-${resolvedMobileVariant}`,
177
+ { 'pageContent--fill': fill },
178
+ ]"
71
179
  >
72
180
  <slot name="content">
73
181
  <!-- Default slot for content without explicit template -->
@@ -79,10 +187,18 @@ const hasSidebar = computed(() => regions.value.start.present)
79
187
 
80
188
  <style>
81
189
 
82
- .slide-fade-enter-from:has(.app-header),
83
- .slide-fade-leave-to:has(.app-header) {
84
- transform: translateX(0) !important;
85
- opacity: 1 !important;
190
+ /* Inputs dropped straight into a header slot default to a natural, compact width
191
+ instead of the input wrapper's global width:100% — otherwise a bare
192
+ <TextInput> (no width class) tries to fill the whole side and gets bumped onto
193
+ its own row. Views that DO want a specific width just add a utility: w-200px /
194
+ w-280px set max-width (compose fine with width:auto), and full-bleed cases use
195
+ m_w-1000px (mobile, where this desktop rule doesn't apply). Desktop only. */
196
+ @media screen and (min-width: 911px) {
197
+ .app-header .bagel-input {
198
+ width: auto;
199
+ min-width: 220px;
200
+ max-width: 100%;
201
+ }
86
202
  }
87
203
 
88
204
  .slide-fade-enter-from:has(.app-header) .pageContent,
@@ -97,16 +213,223 @@ transition: all 0.15s ease-in;
97
213
  overflow: hidden;
98
214
  }
99
215
 
216
+ /* Gutter ownership, breakpoint-aware (see AppLayout `.page-content`):
217
+ • Desktop — `.page-content` supplies the gutter, so an AppContent nested in it drops
218
+ its OWN inline padding to avoid doubling; header + body align with the parent edge.
219
+ • Mobile — `.page-content` drops its gutter, so AppContent keeps its own padding
220
+ (declared in the scoped block): the header runs edge-to-edge (its border reaches
221
+ the screen) while its content stays inset. A standalone AppContent (no
222
+ `.page-content` ancestor) always keeps its own padding — this rule simply doesn't
223
+ match there. Unscoped because `.page-content` belongs to AppLayout's scope. */
224
+ @media screen and (min-width: 911px) {
225
+ /* Repeated class (`.app-content.app-content`) lifts specificity above AppContent's
226
+ own scoped `.app-header[data-v]` / `.pageContent[data-v]` padding rule (which is
227
+ also two-specificity), so this reliably wins and zeroes the doubled gutter. */
228
+ .page-content .app-content.app-content .app-header,
229
+ .page-content .app-content.app-content .pageContent {
230
+ padding-inline: 0;
231
+ }
232
+ }
233
+
100
234
  </style>
101
235
 
102
236
  <style scoped>
103
237
 
104
- .app-content {
105
- height: 100%;
238
+ /* Both the header and the body carry the side gutter themselves (AppLayout's
239
+ `.page-content` no longer applies it). They read the same `--bgl-content-padding`
240
+ so their leading edges line up. The header keeps this padding on its OWN element
241
+ while its bottom border spans the full element width — since `.page-content` is
242
+ now flush to the screen edge, that border reaches the screen edge for free (the
243
+ old negative-margin hack is gone). The body insets its content by the same gutter. */
244
+ .pageContent {
245
+ padding-inline: var(--bgl-content-padding, 1rem);
106
246
  }
107
247
 
248
+ /* The header holds a fixed *minimum* height on every breakpoint — its start
249
+ corner never shifts between pages, whatever it holds (title, input, buttons),
250
+ because the floor is identical everywhere. When content genuinely needs more
251
+ room (long toolbars, small screens) it grows downward from that same floor
252
+ rather than being clipped — the leading corner stays put, only the height
253
+ below it expands. Inline padding aligns to the page's content gutter so the
254
+ header's leading edge lines up with the body. */
108
255
  .app-header {
109
256
  flex-shrink: 0;
257
+ box-sizing: border-box;
258
+ min-height: var(--bgl-app-header-h, 60px);
259
+ gap: 0.5rem 0.75rem;
260
+ padding-inline: var(--bgl-content-padding, 1rem);
261
+ padding-block: 0.5rem;
262
+ flex-wrap: wrap;
263
+ }
264
+
265
+ /* Mobile: headers wrap to multiple rows far more often (narrow viewport), so the
266
+ between-row gap is tightened — the airy 0.5rem that reads fine as a rare desktop
267
+ wrap looks like dead space when the header routinely stacks on a phone. */
268
+ @media screen and (max-width: 910px) {
269
+ .app-header {
270
+ row-gap: 0.25rem;
271
+ }
272
+ }
273
+
274
+ /* Each side hugs its own content — it does NOT grow, so inputs and toolbars keep
275
+ their natural size instead of being stretched to fill the row (which would
276
+ otherwise push a full-width input onto its own line). When the sides can't
277
+ share one row they wrap onto extra rows; min-width:0 + flex-wrap let a single
278
+ over-wide side shrink/wrap its own children rather than spill out. The
279
+ row-gap gives that WITHIN-side wrapping (e.g. a toolbar of buttons breaking
280
+ onto two lines) extra breathing room between its rows — mobile more so. */
281
+ .app-header-side {
282
+ flex: 0 1 auto;
283
+ min-width: 0;
284
+ flex-wrap: wrap;
285
+ max-width: 100%;
286
+ row-gap: 0.5rem;
287
+ }
288
+ /* Same tightening for wrapping WITHIN a side (e.g. a toolbar of buttons breaking
289
+ onto two lines) on mobile. */
290
+ @media screen and (max-width: 910px) {
291
+ .app-header-side {
292
+ row-gap: 0.25rem;
293
+ flex-grow: 1;
294
+ }
295
+ }
296
+
297
+ /* The trailing toolbar is pushed to the end of a shared row by an auto margin
298
+ (no flex growth needed, so it never steals width from the leading side). When
299
+ it can't fit and wraps onto its own line the auto margin collapses and it sits
300
+ at the start of that row; justify-content keeps its actions end-aligned either
301
+ way. This holds on every breakpoint — the sides stay on one row whenever they
302
+ fit (the common case: compact left + 1-2 buttons right) and only wrap when the
303
+ content genuinely doesn't fit. A search input that wants a full-width row on
304
+ mobile opts in per-view with `m_w-1000px` ON THE INPUT (its width then exceeds
305
+ the row and it wraps), rather than the header force-stacking every left side. */
306
+ .endNavTools {
307
+ margin-inline-start: auto;
308
+ justify-content: flex-end;
309
+ }
310
+ /* Mobile: don't push the trailing side to the far edge — let it sit right after the
311
+ leading side (no auto margin, start-aligned) so the header reads as one compact
312
+ group instead of splitting title and actions to opposite screen edges. */
313
+ @media screen and (max-width: 910px) {
314
+ .endNavTools {
315
+ margin-inline-start: 0;
316
+ justify-content: flex-start;
317
+ }
318
+ }
319
+
320
+ /* ── Variants ──────────────────────────────────────────────────────────────
321
+ border → hairline under the header (legacy default)
322
+ card → header floats as a rounded, elevated card above the body
323
+ none → flush, no chrome
324
+
325
+ The desktop variant classes are scoped to the wide breakpoint and the mobile
326
+ `--m-*` classes to the narrow one, so a header can carry both (e.g. card on
327
+ desktop, none on mobile) and the right one wins purely by media query — no
328
+ specificity juggling. Shared framing lives in a mixin-style duplicate below. */
329
+ @media screen and (min-width: 911px) {
330
+ .app-header--border {
331
+ border-bottom: 1px solid var(--bgl-border-color);
332
+ }
333
+ .app-header--card {
334
+ background: var(--bgl-box-bg);
335
+ border: 1px solid var(--bgl-border-color);
336
+ border-radius: var(--bgl-radius, 0.75rem);
337
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
338
+ margin: 0.5rem;
339
+ padding-inline: 0.75rem;
340
+ }
341
+ }
342
+ @media screen and (max-width: 910px) {
343
+ .app-header--m-border {
344
+ border-bottom: 1px solid var(--bgl-border-color);
345
+ }
346
+ .app-header--m-card {
347
+ background: var(--bgl-box-bg);
348
+ border: 1px solid var(--bgl-border-color);
349
+ border-radius: var(--bgl-radius, 0.75rem);
350
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
351
+ margin: 0.5rem;
352
+ padding-inline: 0.75rem;
353
+ }
354
+ /* `none` on mobile: make sure no inherited chrome lingers. */
355
+ .app-header--m-none {
356
+ border-bottom: 0;
357
+ background: none;
358
+ box-shadow: none;
359
+ }
360
+ }
361
+
362
+ /* Hard-lock to a single row of exactly `headerHeight` — no wrapping, overflow
363
+ clipped. Turns the min-height floor into an exact height. */
364
+ .app-header--single-row {
365
+ height: var(--bgl-app-header-h, 60px);
366
+ flex-wrap: nowrap;
367
+ }
368
+ .app-header--single-row .app-header-side {
369
+ overflow: hidden;
370
+ }
371
+
372
+ /* Contained mode (`maxWidth` prop): cap + center ONCE on the shared parent, so
373
+ the header and body inherit the same width and are aligned by construction —
374
+ no per-child centering margins to keep in sync. The effective width keeps a
375
+ safety gutter below the cap (`100% - 2*gutter`) and hits `maxWidth` above it.
376
+ The inner column is centered with a single auto margin. */
377
+ .app-content--contained {
378
+ width: min(100% - 2 * var(--bgl-content-padding, 1rem), var(--bgl-app-max-width, 100%));
379
+ margin-inline: auto;
380
+ }
381
+ /* Contained already supplies the side whitespace via the centered column, so the
382
+ header/body drop their own inline gutter and sit flush to the capped edge. */
383
+ .app-content--contained .app-header,
384
+ .app-content--contained .pageContent {
385
+ padding-inline: 0;
386
+ }
387
+ /* …except a card header, whose rounded frame still needs its inner inset. */
388
+ .app-content--contained .app-header--card {
389
+ padding-inline: 0.75rem;
390
+ }
391
+
392
+ /* Gap under the header, per variant. A bordered header keeps some breathing room
393
+ below the hairline; a card keeps a small gap so the body doesn't touch the
394
+ card's frame; a borderless (none) header sits fully flush so the body hugs it.
395
+ Desktop and mobile are tuned separately — the mobile gaps are tighter (a phone
396
+ has little vertical room to spare, and a single-row header felt too airy). */
397
+ @media screen and (min-width: 911px) {
398
+ .pageContent--border {
399
+ padding-top: 1rem;
400
+ }
401
+ .pageContent--card {
402
+ padding-top: 0.25rem;
403
+ }
404
+ .pageContent--none {
405
+ padding-top: 0;
406
+ }
407
+ }
408
+ @media screen and (max-width: 910px) {
409
+ .pageContent--m-border {
410
+ padding-top: 0.25rem;
411
+ }
412
+ .pageContent--m-card {
413
+ padding-top: 0.25rem;
414
+ }
415
+ .pageContent--m-none {
416
+ padding-top: 0;
417
+ }
418
+ }
419
+
420
+ /* `fill` mode: the body becomes a flex column so its single child fills the whole
421
+ region (full width, remaining height) and owns its own scrolling — no need to
422
+ repeat `h-100p w-100p overflow-hidden` on the child. The `> *` targets the one
423
+ direct child; multiple children still each span full width and stack. */
424
+ .pageContent--fill {
425
+ display: flex;
426
+ flex-direction: column;
427
+ overflow: hidden;
428
+ }
429
+ .pageContent--fill > * {
430
+ flex: 1 1 auto;
431
+ min-height: 0;
432
+ width: 100%;
110
433
  }
111
434
 
112
435
  main {
@@ -318,11 +318,23 @@ transition: margin 300ms ease;
318
318
  min-height: 0;
319
319
  }
320
320
 
321
- /* The single content gutter lives here — the one element common to every
322
- archetype (with/without AppContent, any nav placement). */
321
+ /* Content gutter, breakpoint-aware:
322
+ Desktop — `.page-content` applies the side gutter (as it always did), so every
323
+ view (with or without AppContent) is inset and aligned. An AppContent header
324
+ therefore lines up with its body here; edge-to-edge is opt-in via `edgeHeader`.
325
+ • Mobile — the gutter is dropped from `.page-content` so an AppContent header can
326
+ run flush to the screen edge (full-width divider — the common mobile look). The
327
+ header/body still inset their CONTENT because AppContent applies the same
328
+ `--bgl-content-padding` on its own elements; direct-in-slot views keep their own
329
+ mobile spacing (`m_p-*`). The var stays the single gutter knob. */
323
330
  .page-content {
324
331
  overflow: auto;
325
332
  padding-inline: var(--bgl-content-padding, 1rem);
326
333
  }
334
+ @media screen and (max-width: 910px) {
335
+ .page-content {
336
+ padding-inline: 0;
337
+ }
338
+ }
327
339
 
328
340
  </style>
@@ -66,6 +66,15 @@ function inertRegions(): Ref<Record<RegionSide, RegionState>> {
66
66
  })
67
67
  }
68
68
 
69
+ /**
70
+ * True when called from inside a real `<AppLayout>` (a provider is present).
71
+ * Lets a child (e.g. AppContent) adapt its layout when it's nested in the shell
72
+ * vs. used standalone — without emitting the dev warning `useAppLayout` does.
73
+ */
74
+ export function useIsInsideAppLayout(): boolean {
75
+ return inject(AppLayoutKey, null) !== null
76
+ }
77
+
69
78
  /**
70
79
  * Access the AppLayout context (region state, sidebar open state, mobile flag).
71
80
  * Works inside AppLayout's slots — including custom header buttons.
@@ -7765,4 +7765,14 @@
7765
7765
  .mobile-only {
7766
7766
  display: none !important;
7767
7767
  }
7768
+ }
7769
+
7770
+ .page-gutter {
7771
+ /* The app's side gutter as a utility. AppLayout's `.page-content` no longer
7772
+ applies `padding-inline` itself; the content supplies it, so an AppContent
7773
+ header can sit flush to the screen edge. AppContent's body does this
7774
+ internally; a view rendered DIRECTLY in the layout slot (no AppContent
7775
+ wrapper) adds `.page-gutter` to its root for the same inset. Reads the
7776
+ `--bgl-content-padding` var from AppLayout (falls back to 1rem elsewhere). */
7777
+ padding-inline: var(--bgl-content-padding, 1rem);
7768
7778
  }