@adia-ai/web-components 0.0.23 → 0.0.25

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.
Files changed (38) hide show
  1. package/components/app-shell/app-shell.a2ui.json +136 -0
  2. package/components/app-shell/app-shell.css +16 -0
  3. package/components/app-shell/app-shell.js +202 -0
  4. package/components/app-shell/app-shell.yaml +183 -0
  5. package/components/aside/aside.a2ui.json +84 -0
  6. package/components/aside/aside.yaml +100 -0
  7. package/components/button/button.css +7 -5
  8. package/components/check/check.css +24 -27
  9. package/components/drawer/drawer.css +356 -349
  10. package/components/drawer/drawer.js +44 -11
  11. package/components/footer/footer.a2ui.json +1 -1
  12. package/components/footer/footer.yaml +1 -1
  13. package/components/header/header.a2ui.json +2 -2
  14. package/components/header/header.yaml +2 -2
  15. package/components/index.js +2 -0
  16. package/components/input/input.css +13 -11
  17. package/components/kbd/kbd.css +1 -1
  18. package/components/modal/modal.js +12 -11
  19. package/components/option-card/option-card.css +28 -36
  20. package/components/page/page.a2ui.json +107 -0
  21. package/components/page/page.css +68 -0
  22. package/components/page/page.js +88 -0
  23. package/components/page/page.yaml +148 -0
  24. package/components/radio/radio.css +13 -14
  25. package/components/range/range.css +8 -4
  26. package/components/section/section.a2ui.json +1 -1
  27. package/components/section/section.yaml +1 -1
  28. package/components/segment/segment.css +7 -7
  29. package/components/switch/switch.css +13 -11
  30. package/components/textarea/textarea.css +10 -5
  31. package/components/toast/toast.css +27 -26
  32. package/components/toggle-group/toggle-group.css +4 -3
  33. package/components/tree/tree.css +21 -9
  34. package/package.json +1 -1
  35. package/patterns/app-nav-item/app-nav-item.css +24 -24
  36. package/patterns/app-shell/app-shell.css +12 -0
  37. package/patterns/section-nav-item/section-nav-item.css +23 -24
  38. package/styles/components.css +2 -0
@@ -36,7 +36,6 @@ class AdiaDrawer extends AdiaElement {
36
36
  #closing = false;
37
37
  #previousFocus = null;
38
38
  #closeTimer = null;
39
- #openRaf = null;
40
39
  #dialogRef = null;
41
40
 
42
41
  static properties = {
@@ -60,6 +59,28 @@ class AdiaDrawer extends AdiaElement {
60
59
  // html`` result would trigger stamp() → replaceChildren(), wiping authored
61
60
  // [slot=header|body|footer] before render() can migrate them into the panel.
62
61
 
62
+ constructor() {
63
+ super();
64
+ // Safari requires <dialog>.showModal() to be invoked synchronously inside
65
+ // the click handler. The reactive system schedules render() in a microtask
66
+ // after the property change, which Safari treats as outside the user-gesture
67
+ // window and silently no-ops the showModal. Wrap the auto-installed `open`
68
+ // setter so dialog state syncs in the same synchronous frame as the
69
+ // assignment. See docs/BROWSER-COMPAT.md §3a (Flavor C).
70
+ const desc = Object.getOwnPropertyDescriptor(this, 'open');
71
+ if (desc?.set) {
72
+ const origSet = desc.set;
73
+ Object.defineProperty(this, 'open', {
74
+ get: desc.get,
75
+ set: (v) => {
76
+ origSet.call(this, v);
77
+ this.#syncDialog();
78
+ },
79
+ configurable: true,
80
+ });
81
+ }
82
+ }
83
+
63
84
  #onPress = (e) => {
64
85
  if (e.target.closest('[slot="close"]')) this.open = false;
65
86
  };
@@ -101,10 +122,6 @@ class AdiaDrawer extends AdiaElement {
101
122
  this.#dialogRef.removeEventListener('close', this.#onDialogClose);
102
123
  this.#dialogRef.removeEventListener('click', this.#onDialogClick);
103
124
  }
104
- if (this.#openRaf != null) {
105
- cancelAnimationFrame(this.#openRaf);
106
- this.#openRaf = null;
107
- }
108
125
  if (this.#closeTimer != null) {
109
126
  clearTimeout(this.#closeTimer);
110
127
  this.#closeTimer = null;
@@ -228,15 +245,26 @@ class AdiaDrawer extends AdiaElement {
228
245
  if (userFooter.parentElement !== panel) panel.appendChild(userFooter);
229
246
  }
230
247
 
231
- // Sync open state
248
+ // Sync open state — also syncs synchronously from the `open` setter
249
+ // (see constructor) so showModal() runs in the click handler's gesture
250
+ // frame on Safari.
251
+ this.#syncDialog();
252
+ }
253
+
254
+ #syncDialog() {
255
+ const dialog = this.#dialogRef;
256
+ if (!dialog) return;
232
257
  if (this.open && !dialog.open) {
233
258
  this.#closing = false;
234
259
  this.#previousFocus = document.activeElement;
235
260
  dialog.showModal();
236
- this.#openRaf = requestAnimationFrame(() => {
237
- this.#openRaf = null;
238
- dialog.setAttribute('data-open', '');
239
- });
261
+ // Synchronous reflow instead of rAF — Safari throttles
262
+ // requestAnimationFrame when a top-layer dialog is open, sometimes
263
+ // delaying [data-open] (and the slide-in transition) by tens of
264
+ // seconds. Forcing a reflow keeps the animation start in the same
265
+ // synchronous frame. See docs/BROWSER-COMPAT.md §3a (Flavor C).
266
+ void dialog.offsetHeight;
267
+ dialog.setAttribute('data-open', '');
240
268
  } else if (!this.open && dialog.open && !this.#closing) {
241
269
  this.#animateClose(dialog);
242
270
  }
@@ -244,8 +272,13 @@ class AdiaDrawer extends AdiaElement {
244
272
 
245
273
  #animateClose(dialog) {
246
274
  this.#closing = true;
247
- dialog.removeAttribute('data-open');
275
+ // Set [data-closing] FIRST (carries the transition spec), force a
276
+ // reflow, THEN remove [data-open]. If both attribute changes batch
277
+ // into a single style update, Safari can skip the slide-out animation
278
+ // entirely.
248
279
  dialog.setAttribute('data-closing', '');
280
+ void dialog.offsetHeight;
281
+ dialog.removeAttribute('data-open');
249
282
 
250
283
  this.#closeTimer = setTimeout(() => {
251
284
  this.#closeTimer = null;
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/Footer.json",
4
4
  "title": "Footer",
5
- "description": "Card footer. Contains actions, pagination, or summary. Typically holds Buttons.",
5
+ "description": "Footer — styled by closest container parent (Card / Drawer / Modal / Page / AppShell). Contains actions, pagination, or summary. Typically holds Buttons.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -6,7 +6,7 @@ tag: footer-ui
6
6
  component: Footer
7
7
  category: container
8
8
  version: 1
9
- description: Card footer. Contains actions, pagination, or summary. Typically holds Buttons.
9
+ description: Footer — styled by closest container parent (Card / Drawer / Modal / Page / AppShell). Contains actions, pagination, or summary. Typically holds Buttons.
10
10
  props:
11
11
  justify:
12
12
  description: Horizontal alignment of children
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/Header.json",
4
4
  "title": "Header",
5
- "description": "Card header. Contains heading text and optional action slot.",
5
+ "description": "Header — styled by closest container parent (Card / Drawer / Modal / Page / AppShell). Contains heading text and optional action slot.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -17,7 +17,7 @@
17
17
  "const": "Header"
18
18
  },
19
19
  "padding": {
20
- "description": "Bare attribute — enables default header padding. Card's own `padding` prop sets scale.",
20
+ "description": "Bare attribute — enables default header padding. The container parent's own `padding` prop sets the scale.",
21
21
  "type": "boolean",
22
22
  "default": false
23
23
  }
@@ -6,10 +6,10 @@ tag: header-ui
6
6
  component: Header
7
7
  category: container
8
8
  version: 1
9
- description: Card header. Contains heading text and optional action slot.
9
+ description: Header — styled by closest container parent (Card / Drawer / Modal / Page / AppShell). Contains heading text and optional action slot.
10
10
  props:
11
11
  padding:
12
- description: Bare attribute — enables default header padding. Card's own `padding` prop sets scale.
12
+ description: Bare attribute — enables default header padding. The container parent's own `padding` prop sets the scale.
13
13
  type: boolean
14
14
  default: false
15
15
  reflect: true
@@ -24,6 +24,8 @@ export { AdiaSegmented } from './segmented/segmented.js';
24
24
  export { AdiaRange } from './range/range.js';
25
25
  export { AdiaTree, AdiaTreeItem } from './tree/tree.js';
26
26
  export { AdiaPane } from './pane/pane.js';
27
+ export { AdiaAppShell } from './app-shell/app-shell.js';
28
+ export { AdiaPage } from './page/page.js';
27
29
  export { AdiaChatInput } from './chat/chat-input.js';
28
30
  export { AdiaChat } from './chat/chat.js';
29
31
  export { AdiaDrawer } from './drawer/drawer.js';
@@ -1,8 +1,18 @@
1
- /* Safari 17.x bug: `:scope[attr]:hover` inside `@scope` doesn't match the
2
- scope root. Plain selector outside works. See docs/BROWSER-COMPAT.md §3a. */
1
+ /* Safari 17.x bug: `:scope[attr]:hover` and `:scope:not(...) [descendant]:hover`
2
+ inside `@scope` don't match the scope root. Plain selectors outside work.
3
+ See docs/BROWSER-COMPAT.md §3a. */
3
4
  input-ui[variant="ghost"]:hover {
4
5
  --input-bg: var(--a-bg-muted);
5
6
  }
7
+ input-ui:not([disabled]) [slot="field"]:hover {
8
+ background: var(--input-bg-hover);
9
+ border-color: var(--input-border-hover);
10
+ color: var(--input-fg-hover);
11
+ }
12
+ input-ui:not([disabled]) [slot="field"]:hover [slot="prefix"],
13
+ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
14
+ color: var(--input-affix-fg-hover);
15
+ }
6
16
 
7
17
  @scope (input-ui) {
8
18
  :where(:scope) {
@@ -83,15 +93,7 @@ input-ui[variant="ghost"]:hover {
83
93
  color var(--input-duration) var(--input-easing),
84
94
  box-shadow var(--input-duration) var(--input-easing);
85
95
  }
86
- :scope:not([disabled]) [slot="field"]:hover {
87
- background: var(--input-bg-hover);
88
- border-color: var(--input-border-hover);
89
- color: var(--input-fg-hover);
90
- }
91
- :scope:not([disabled]) [slot="field"]:hover [slot="prefix"],
92
- :scope:not([disabled]) [slot="field"]:hover [slot="suffix"] {
93
- color: var(--input-affix-fg-hover);
94
- }
96
+ /* hover rules moved outside @scope — see Safari 17.x bug note at top. */
95
97
  :scope:not([disabled]):focus-within [slot="field"] {
96
98
  /* Canonical ring — consumes the L3 --input-focus-ring token
97
99
  which aliases --a-focus-ring. Border stays stable; the ring
@@ -8,7 +8,7 @@
8
8
  --kbd-font: var(--a-font-family-code);
9
9
 
10
10
  /* Size — defaults to md */
11
- --kbd-font-size: var(--a-ui-sm);
11
+ --kbd-font-size: var(--a-ui-tiny);
12
12
  --kbd-height: 1.25rem;
13
13
  --kbd-min-width: 1.25rem;
14
14
  --kbd-px: var(--a-space-1);
@@ -36,7 +36,6 @@ class AdiaModal extends AdiaElement {
36
36
  #closing = false;
37
37
  #previousFocus = null;
38
38
  #closeTimer = null;
39
- #openRaf = null;
40
39
  #dialogRef = null;
41
40
 
42
41
  static properties = {
@@ -101,10 +100,6 @@ class AdiaModal extends AdiaElement {
101
100
  this.#dialogRef.removeEventListener('close', this.#onDialogClose);
102
101
  this.#dialogRef.removeEventListener('click', this.#onDialogClick);
103
102
  }
104
- if (this.#openRaf != null) {
105
- cancelAnimationFrame(this.#openRaf);
106
- this.#openRaf = null;
107
- }
108
103
  if (this.#closeTimer != null) {
109
104
  clearTimeout(this.#closeTimer);
110
105
  this.#closeTimer = null;
@@ -185,15 +180,16 @@ class AdiaModal extends AdiaElement {
185
180
  if (footer.parentElement !== panel) panel.appendChild(footer);
186
181
  }
187
182
 
188
- // Sync open state
183
+ // Sync open state. Synchronous reflow instead of rAF — Safari throttles
184
+ // requestAnimationFrame when a top-layer dialog is open, sometimes
185
+ // delaying [data-open] (and the scale-in transition) by tens of seconds.
186
+ // See docs/BROWSER-COMPAT.md §3a (Flavor C).
189
187
  if (this.open && !dialog.open) {
190
188
  this.#closing = false;
191
189
  this.#previousFocus = document.activeElement;
192
190
  dialog.showModal();
193
- this.#openRaf = requestAnimationFrame(() => {
194
- this.#openRaf = null;
195
- dialog.setAttribute('data-open', '');
196
- });
191
+ void dialog.offsetHeight;
192
+ dialog.setAttribute('data-open', '');
197
193
  } else if (!this.open && dialog.open && !this.#closing) {
198
194
  this.#animateClose(dialog);
199
195
  }
@@ -201,8 +197,13 @@ class AdiaModal extends AdiaElement {
201
197
 
202
198
  #animateClose(dialog) {
203
199
  this.#closing = true;
204
- dialog.removeAttribute('data-open');
200
+ // Set [data-closing] FIRST (carries the transition spec), force a
201
+ // reflow, THEN remove [data-open]. If both attribute changes batch
202
+ // into a single style update, Safari can skip the fade-out animation
203
+ // entirely.
205
204
  dialog.setAttribute('data-closing', '');
205
+ void dialog.offsetHeight;
206
+ dialog.removeAttribute('data-open');
206
207
 
207
208
  this.#closeTimer = setTimeout(() => {
208
209
  this.#closeTimer = null;
@@ -1,10 +1,32 @@
1
- /* Safari 17.x bug: `:scope:not(...):hover` inside `@scope` doesn't match
2
- the scope root. Plain selector outside works. See
3
- docs/BROWSER-COMPAT.md §3a. */
1
+ /* Safari 17.x bug: `:scope:not(...):hover` (Flavor A) and `:scope[checked]`
2
+ (Flavor B attribute-removal restyle) both fail inside `@scope`.
3
+ Selectors moved out. See docs/BROWSER-COMPAT.md §3a. */
4
4
  option-card-ui:not([checked]):not([disabled]):hover {
5
5
  background: var(--option-card-bg-hover);
6
6
  border-color: var(--option-card-border-hover);
7
7
  }
8
+ option-card-ui[checked] > :not([slot]) {
9
+ display: block;
10
+ }
11
+ option-card-ui[checked] {
12
+ background: var(--option-card-bg-checked);
13
+ border-color: var(--option-card-border-checked);
14
+ }
15
+ option-card-ui[checked]::before {
16
+ border-color: var(--option-card-radio-fill);
17
+ background:
18
+ radial-gradient(
19
+ circle,
20
+ var(--option-card-radio-dot) 0 30%,
21
+ var(--option-card-radio-fill) 30% 100%
22
+ );
23
+ }
24
+ option-card-ui[checked] > [slot="heading"] {
25
+ color: var(--option-card-heading-color-checked);
26
+ }
27
+ option-card-ui[checked] > [slot="icon"] {
28
+ color: var(--option-card-icon-color-checked);
29
+ }
8
30
 
9
31
  @scope (option-card-ui) {
10
32
  :where(:scope) {
@@ -142,39 +164,9 @@ option-card-ui:not([checked]):not([disabled]):hover {
142
164
  :scope:has(> [slot="icon"]) > :not([slot]) {
143
165
  grid-column: 3 / -1;
144
166
  }
145
- :scope[checked] > :not([slot]) {
146
- display: block;
147
- }
148
-
149
- /* hover rule moved outside @scope — see Safari 17.x bug note at top. */
150
-
151
- /* ── State: checked — accent border + tinted bg + filled radio.
152
- The indicator becomes an accent disc with a centered dot of
153
- --option-card-radio-dot at 60% of the size, mirroring
154
- radio-ui's recipe (radio.css:75-78). Done with a radial
155
- gradient so a single pseudo-element carries both layers. */
156
- :scope[checked] {
157
- background: var(--option-card-bg-checked);
158
- border-color: var(--option-card-border-checked);
159
- }
160
- :scope[checked]::before {
161
- border-color: var(--option-card-radio-fill);
162
- background:
163
- radial-gradient(
164
- circle,
165
- var(--option-card-radio-dot) 0 30%,
166
- var(--option-card-radio-fill) 30% 100%
167
- );
168
- }
169
- /* Heading + icon shift to a strong color when checked — gives the
170
- selected card a clear text-level emphasis on top of the bg/border
171
- state, so picking is unambiguous beyond the radio dot alone. */
172
- :scope[checked] > [slot="heading"] {
173
- color: var(--option-card-heading-color-checked);
174
- }
175
- :scope[checked] > [slot="icon"] {
176
- color: var(--option-card-icon-color-checked);
177
- }
167
+ /* hover + [checked] state rules moved outside @scope — see Safari 17.x bug note at top.
168
+ The :scope[checked]::before recipe lives at top-of-file: an accent disc
169
+ with a centered dot via radial-gradient, mirroring radio-ui's recipe. */
178
170
 
179
171
  /* ── Layout: tile — icon top-left, indicator top-right, heading +
180
172
  description below, all left-aligned. Used for hero pickers
@@ -0,0 +1,107 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/Page.json",
4
+ "title": "Page",
5
+ "description": "Page container. Holds page-level chrome — header / content / footer —\nand manages max-width clamps, padding scale, optional scroll-container,\nand an optional sticky-header sentinel. Compose with the slot\nprimitives (`<header-ui>`, `<section-ui>`, `<footer-ui>`); the page's\n@scope rules style them. Drop in directly, or nest inside an\n`<app-shell-ui>`'s main column.\n",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "component": {
17
+ "const": "Page"
18
+ },
19
+ "maxWidth": {
20
+ "description": "Token-bound max-width clamp. `prose` (65ch) for reading pages,\n`narrow` (80ch) for tight forms, `wide` (1080px) for data-rich\npages, `full` for unconstrained. Empty defers to parent / 100%.\nCentered horizontally via `margin-inline: auto`.\n",
21
+ "type": "string",
22
+ "enum": [
23
+ "",
24
+ "prose",
25
+ "narrow",
26
+ "wide",
27
+ "full"
28
+ ],
29
+ "default": ""
30
+ },
31
+ "padding": {
32
+ "description": "Page-padding scale from the spacing system. Accepts `0`–`8`\n(mapped to `--a-space-N`). Empty (no value) applies the\n`--page-padding-default` token; `0` removes padding.\n",
33
+ "type": "string",
34
+ "default": ""
35
+ },
36
+ "scroll": {
37
+ "description": "Sets the page as a scroll container. `overflow-y: auto`, full\nheight, contained overscroll. Use when the page IS the scroll\nsurface (standalone pages); leave off when nested inside a parent\nthat already manages scroll (e.g. inside an `<app-shell-ui>`'s\nmain `<section>`).\n",
38
+ "type": "boolean",
39
+ "default": false
40
+ },
41
+ "stickyHeader": {
42
+ "description": "Installs an IntersectionObserver sentinel before the first\n`<header>` / `<header-ui>` child. When the sentinel scrolls out\nof view the page gains `[data-header-stuck]`, which the CSS\nuses to add a border + shadow to the header. No-op when no\nheader is present.\n",
43
+ "type": "boolean",
44
+ "default": false
45
+ }
46
+ },
47
+ "required": [
48
+ "component"
49
+ ],
50
+ "unevaluatedProperties": false,
51
+ "x-adiaui": {
52
+ "anti_patterns": [],
53
+ "category": "container",
54
+ "events": {},
55
+ "examples": [
56
+ {
57
+ "description": "Reading page with sticky header, 65ch column, padding scale 6.",
58
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Page\",\n \"stickyHeader\": true,\n \"maxWidth\": \"prose\",\n \"padding\": \"6\",\n \"children\": [\"hdr\", \"body\"]\n },\n {\n \"id\": \"hdr\",\n \"component\": \"Header\",\n \"children\": [\"title\"]\n },\n {\n \"id\": \"title\",\n \"component\": \"Text\",\n \"variant\": \"display\",\n \"textContent\": \"Reading Page\"\n },\n {\n \"id\": \"body\",\n \"component\": \"Section\",\n \"children\": []\n }\n]",
59
+ "name": "prose-page"
60
+ },
61
+ {
62
+ "description": "Wide-clamp dashboard page acting as a scroll container.",
63
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Page\",\n \"scroll\": true,\n \"maxWidth\": \"wide\",\n \"padding\": \"4\",\n \"children\": [\"hdr\", \"body\"]\n },\n {\n \"id\": \"hdr\",\n \"component\": \"Header\",\n \"children\": []\n },\n {\n \"id\": \"body\",\n \"component\": \"Section\",\n \"children\": []\n }\n]",
64
+ "name": "dashboard-page"
65
+ }
66
+ ],
67
+ "keywords": [
68
+ "page",
69
+ "layout",
70
+ "container",
71
+ "scroll",
72
+ "sticky-header",
73
+ "max-width",
74
+ "padding",
75
+ "prose-page",
76
+ "dashboard-page"
77
+ ],
78
+ "name": "AdiaPage",
79
+ "related": [
80
+ "app-shell",
81
+ "card",
82
+ "section",
83
+ "header",
84
+ "footer"
85
+ ],
86
+ "slots": {
87
+ "default": {
88
+ "description": "Composes from the slot primitives — `<header-ui>` (page header),\n`<section-ui>` (main content), optional `<footer-ui>`. Native\n`<header>` / `<section>` / `<footer>` also work; the @scope rules\ntarget both via `:where(header, header-ui)`.\n"
89
+ }
90
+ },
91
+ "states": [
92
+ {
93
+ "description": "Default, ready for interaction.",
94
+ "name": "idle"
95
+ },
96
+ {
97
+ "description": "Header has scrolled past the sentinel; visual cue applied.",
98
+ "name": "header-stuck"
99
+ }
100
+ ],
101
+ "synonyms": {},
102
+ "tag": "page-ui",
103
+ "tokens": {},
104
+ "traits": [],
105
+ "version": 1
106
+ }
107
+ }
@@ -0,0 +1,68 @@
1
+ @scope (page-ui) {
2
+ :where(:scope) {
3
+ /* ── Max-width clamps ── */
4
+ --page-max-width-prose: 65ch;
5
+ --page-max-width-narrow: 80ch;
6
+ --page-max-width-wide: 1080px;
7
+ --page-max-width-full: 100%;
8
+
9
+ /* ── Padding default (when [padding] is set without a value) ── */
10
+ --page-padding-default: var(--a-space-6);
11
+
12
+ /* ── Surfaces ── */
13
+ --page-bg: var(--a-canvas-0);
14
+ --page-fg: var(--a-fg);
15
+
16
+ /* ── Sticky-header chrome (when [data-header-stuck]) ── */
17
+ --page-sticky-bg: var(--a-canvas-0);
18
+ --page-sticky-border: 1px solid var(--a-border-subtle);
19
+ --page-sticky-shadow: var(--a-shadow-sm);
20
+ }
21
+
22
+ :scope {
23
+ box-sizing: border-box;
24
+ display: block;
25
+ width: 100%;
26
+ background: var(--page-bg);
27
+ color: var(--page-fg);
28
+ }
29
+
30
+ /* ── max-width clamps ── */
31
+ :scope[max-width="prose"] { max-width: var(--page-max-width-prose); margin-inline: auto; }
32
+ :scope[max-width="narrow"] { max-width: var(--page-max-width-narrow); margin-inline: auto; }
33
+ :scope[max-width="wide"] { max-width: var(--page-max-width-wide); margin-inline: auto; }
34
+ :scope[max-width="full"] { max-width: var(--page-max-width-full); }
35
+
36
+ /* ── Padding scale (mirrors --a-space-N) ── */
37
+ :scope[padding=""] { padding: var(--page-padding-default); }
38
+ :scope[padding="0"] { padding: 0; }
39
+ :scope[padding="1"] { padding: var(--a-space-1); }
40
+ :scope[padding="2"] { padding: var(--a-space-2); }
41
+ :scope[padding="3"] { padding: var(--a-space-3); }
42
+ :scope[padding="4"] { padding: var(--a-space-4); }
43
+ :scope[padding="5"] { padding: var(--a-space-5); }
44
+ :scope[padding="6"] { padding: var(--a-space-6); }
45
+ :scope[padding="7"] { padding: var(--a-space-7); }
46
+ :scope[padding="8"] { padding: var(--a-space-8); }
47
+
48
+ /* ── Scroll container ── */
49
+ :scope[scroll] {
50
+ overflow-y: auto;
51
+ height: 100%;
52
+ overscroll-behavior: contain;
53
+ }
54
+
55
+ /* ── Sticky-header support ── */
56
+ :scope[sticky-header] > :where(header, header-ui) {
57
+ position: sticky;
58
+ top: 0;
59
+ z-index: 1;
60
+ background: var(--page-sticky-bg);
61
+ transition: border-color 150ms ease, box-shadow 150ms ease;
62
+ }
63
+
64
+ :scope[data-header-stuck] > :where(header, header-ui) {
65
+ border-block-end: var(--page-sticky-border);
66
+ box-shadow: var(--page-sticky-shadow);
67
+ }
68
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * <page-ui> — Page container.
3
+ *
4
+ * Holds page-level chrome — header / content / footer — and manages
5
+ * max-width clamps, padding scale, optional scroll-container, and an
6
+ * optional sticky-header sentinel. Compose with the slot primitives
7
+ * (`<header-ui>`, `<section-ui>`, `<footer-ui>`); the page's @scope
8
+ * rules style them.
9
+ *
10
+ * Authoring:
11
+ * <page-ui sticky-header max-width="prose" padding="6">
12
+ * <header-ui>...</header-ui> <!-- page title + actions -->
13
+ * <section-ui>...</section-ui> <!-- main content -->
14
+ * <footer-ui>...</footer-ui> <!-- optional -->
15
+ * </page-ui>
16
+ *
17
+ * Attributes:
18
+ * scroll — boolean. Page is the scroll container (overflow-y: auto).
19
+ * max-width — '' | 'prose' (65ch) | 'narrow' (80ch) | 'wide' (1080px) | 'full' (100%).
20
+ * padding — '' | '0'..'8' (mapped to --a-space-N).
21
+ * sticky-header — boolean. Installs IntersectionObserver sentinel before the
22
+ * first <header> / <header-ui> child; sets [data-header-stuck]
23
+ * on the page when the sentinel scrolls out of view.
24
+ *
25
+ * ADR: .brain/adrs/0009-promote-app-shell-and-page-to-components.md.
26
+ */
27
+
28
+ import { AdiaElement } from '../../core/element.js';
29
+
30
+ class AdiaPage extends AdiaElement {
31
+ static properties = {
32
+ scroll: { type: Boolean, default: false, reflect: true },
33
+ maxWidth: { type: String, default: '', attribute: 'max-width', reflect: true },
34
+ padding: { type: String, default: '', reflect: true },
35
+ stickyHeader: { type: Boolean, default: false, attribute: 'sticky-header', reflect: true },
36
+ };
37
+
38
+ static template = () => null;
39
+
40
+ #sentinel = null;
41
+ #observer = null;
42
+
43
+ connected() {
44
+ if (this.stickyHeader) this.#installSticky();
45
+ }
46
+
47
+ disconnected() {
48
+ this.#teardownSticky();
49
+ }
50
+
51
+ render() {
52
+ // Sticky-header attribute changes between renders → install / tear down.
53
+ if (this.stickyHeader && !this.#sentinel) this.#installSticky();
54
+ if (!this.stickyHeader && this.#sentinel) this.#teardownSticky();
55
+ }
56
+
57
+ #installSticky() {
58
+ const header = this.querySelector(':scope > :is(header, header-ui)');
59
+ if (!header) return;
60
+
61
+ if (!this.#sentinel) {
62
+ this.#sentinel = document.createElement('div');
63
+ this.#sentinel.setAttribute('data-page-sentinel', '');
64
+ this.#sentinel.style.cssText = 'height: 0; width: 0; pointer-events: none;';
65
+ header.insertAdjacentElement('beforebegin', this.#sentinel);
66
+ }
67
+
68
+ if (!this.#observer) {
69
+ this.#observer = new IntersectionObserver((entries) => {
70
+ this.toggleAttribute('data-header-stuck', !entries[0].isIntersecting);
71
+ }, { threshold: 0 });
72
+ }
73
+
74
+ this.#observer.observe(this.#sentinel);
75
+ }
76
+
77
+ #teardownSticky() {
78
+ this.#observer?.disconnect();
79
+ this.#observer = null;
80
+ this.#sentinel?.remove();
81
+ this.#sentinel = null;
82
+ this.removeAttribute('data-header-stuck');
83
+ }
84
+ }
85
+
86
+ customElements.define('page-ui', AdiaPage);
87
+
88
+ export { AdiaPage };