@karyl-chan/ui 0.3.0 → 0.5.0

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.
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Spacer — an empty flex-grow filler that pushes its siblings apart
4
+ * inside a Stack / Cluster (or any flex container), replacing the
5
+ * one-off `margin-left:auto` / `margin-top:auto` idiom:
6
+ *
7
+ * <Cluster>
8
+ * <Title />
9
+ * <Spacer /> <!-- eats the free space -->
10
+ * <Actions />
11
+ * </Cluster>
12
+ *
13
+ * Purely presentational, so it's hidden from the accessibility tree.
14
+ */
15
+ </script>
16
+
17
+ <template>
18
+ <div class="ui-spacer" aria-hidden="true"></div>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .ui-spacer { flex: 1 1 auto; }
23
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Stack — a flex layout that OWNS the gap between its children.
4
+ *
5
+ * Reach for this instead of hand-authoring `display:flex; flex-direction:
6
+ * column; gap:…` (or per-element margins) in a view's scoped CSS — that
7
+ * pattern appears hundreds of times across the app and is exactly the
8
+ * responsibility a layout primitive should carry, not each component.
9
+ * Because the parent Stack owns the spacing, the components inside it
10
+ * never need (or should have) outer margins of their own.
11
+ *
12
+ * <Stack gap="4"> vertical, 1rem gaps (--space-4)
13
+ * <VoiceCard />
14
+ * <AppTabs ... />
15
+ * <section>…</section>
16
+ * </Stack>
17
+ *
18
+ * `gap` takes a spacing-scale step (0–8 → --space-*) or a raw CSS length.
19
+ */
20
+ import { computed, type CSSProperties } from "vue";
21
+ import {
22
+ resolveSpace,
23
+ ALIGN_ITEMS,
24
+ JUSTIFY_CONTENT,
25
+ type Align,
26
+ type Justify,
27
+ } from "../lib/space";
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Gap between children: a scale step (0–8) or a raw CSS length. */
32
+ gap?: string | number;
33
+ /** Main-axis direction. Default vertical. */
34
+ direction?: "column" | "row";
35
+ align?: Align;
36
+ justify?: Justify;
37
+ /** Allow children to wrap onto multiple lines. */
38
+ wrap?: boolean;
39
+ /** Render as inline-flex (shrink-to-content) instead of flex. */
40
+ inline?: boolean;
41
+ }>(),
42
+ {
43
+ gap: 4,
44
+ direction: "column",
45
+ align: "stretch",
46
+ justify: "start",
47
+ wrap: false,
48
+ inline: false,
49
+ },
50
+ );
51
+
52
+ const style = computed<CSSProperties>(() => ({
53
+ display: props.inline ? "inline-flex" : "flex",
54
+ flexDirection: props.direction,
55
+ gap: resolveSpace(props.gap),
56
+ alignItems: ALIGN_ITEMS[props.align],
57
+ justifyContent: JUSTIFY_CONTENT[props.justify],
58
+ flexWrap: props.wrap ? "wrap" : "nowrap",
59
+ }));
60
+ </script>
61
+
62
+ <template>
63
+ <div class="ui-stack" :style="style"><slot /></div>
64
+ </template>
65
+
66
+ <style scoped>
67
+ /* min-width:0 lets a column stack's children (e.g. text) truncate instead
68
+ of forcing the stack wider than its parent. */
69
+ .ui-stack { min-width: 0; }
70
+ </style>
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Shared WAI-ARIA tabs keyboard model for `AppTabs` and `AppTabsRouted`:
3
+ * roving tabindex + ArrowLeft/ArrowRight (ArrowUp/ArrowDown on vertical
4
+ * lists) / Home / End, skipping disabled tabs and wrapping at the ends.
5
+ * Activation is automatic — moving focus selects the tab.
6
+ */
7
+ import type { TabDef } from "./tab-types";
8
+
9
+ export type TabOrientation = "horizontal" | "vertical";
10
+
11
+ /** Key of the tab that should be in the Tab sequence (tabindex=0). */
12
+ export function rovingTabKey(
13
+ tabs: readonly TabDef[],
14
+ selected: string | undefined,
15
+ ): string | undefined {
16
+ if (selected !== undefined && tabs.some((t) => t.key === selected && !t.disabled)) {
17
+ return selected;
18
+ }
19
+ return tabs.find((t) => !t.disabled)?.key;
20
+ }
21
+
22
+ /**
23
+ * Key of the tab a navigation key moves to, or null when the key isn't a
24
+ * tab-navigation key (or there is nowhere to go).
25
+ */
26
+ export function nextTabKey(
27
+ tabs: readonly TabDef[],
28
+ current: string | undefined,
29
+ key: string,
30
+ orientation: TabOrientation = "horizontal",
31
+ ): string | null {
32
+ const enabled = tabs.filter((t) => !t.disabled);
33
+ if (enabled.length === 0) return null;
34
+
35
+ const forward =
36
+ key === "ArrowRight" || (orientation === "vertical" && key === "ArrowDown");
37
+ const backward =
38
+ key === "ArrowLeft" || (orientation === "vertical" && key === "ArrowUp");
39
+
40
+ if (key === "Home") return enabled[0].key;
41
+ if (key === "End") return enabled[enabled.length - 1].key;
42
+ if (!forward && !backward) return null;
43
+
44
+ const idx = enabled.findIndex((t) => t.key === current);
45
+ if (idx === -1) return forward ? enabled[0].key : enabled[enabled.length - 1].key;
46
+ const step = forward ? 1 : -1;
47
+ return enabled[(idx + step + enabled.length) % enabled.length].key;
48
+ }
49
+
50
+ /**
51
+ * keydown handler for a `role="tablist"` element whose tabs carry
52
+ * `data-tab-key`. Moves focus to the target tab and calls `activate`.
53
+ */
54
+ export function onTablistKeydown(
55
+ event: KeyboardEvent,
56
+ tabs: readonly TabDef[],
57
+ activate: (key: string) => void,
58
+ orientation: TabOrientation = "horizontal",
59
+ ): void {
60
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
61
+ const tabEl = (event.target as HTMLElement | null)?.closest<HTMLElement>(
62
+ '[role="tab"]',
63
+ );
64
+ if (!tabEl) return;
65
+ const current = tabEl.dataset.tabKey;
66
+ const next = nextTabKey(tabs, current, event.key, orientation);
67
+ if (next === null) return;
68
+ event.preventDefault();
69
+ const list = event.currentTarget as HTMLElement;
70
+ const target = Array.from(
71
+ list.querySelectorAll<HTMLElement>('[role="tab"]'),
72
+ ).find((el) => el.dataset.tabKey === next);
73
+ target?.focus();
74
+ if (next !== current) activate(next);
75
+ }
@@ -18,7 +18,12 @@ const pending = ref<PendingConfirm | null>(null);
18
18
  export function useConfirm() {
19
19
  function confirm(opts: ConfirmOptions): Promise<boolean> {
20
20
  return new Promise<boolean>((resolve) => {
21
+ // Only one global confirm can be on screen. A newer request
22
+ // supersedes the old one — settle the old promise as "not
23
+ // confirmed" so its awaiter doesn't hang forever.
24
+ const superseded = pending.value;
21
25
  pending.value = { options: opts, resolve };
26
+ superseded?.resolve(false);
22
27
  });
23
28
  }
24
29
 
@@ -201,8 +201,22 @@ const DEFAULT_OPTIONS: Required<
201
201
  transition: true,
202
202
  };
203
203
 
204
- export function createVirtualElement(x: number, y: number): VirtualElement {
204
+ /**
205
+ * Build a Popper `VirtualElement` anchored at a viewport point — pass it
206
+ * as `AppPopover`'s `referenceEl` (or to `usePopover`) to position a
207
+ * popover at a cursor / canvas coordinate instead of a DOM element.
208
+ *
209
+ * `contextElement` (optional) tells Popper which element's scroll
210
+ * containers to track, so the popover follows the anchor while that
211
+ * region scrolls.
212
+ */
213
+ export function createVirtualElement(
214
+ x: number,
215
+ y: number,
216
+ contextElement?: Element,
217
+ ): VirtualElement {
205
218
  return {
219
+ contextElement,
206
220
  getBoundingClientRect: () => ({
207
221
  width: 0,
208
222
  height: 0,
@@ -223,6 +237,11 @@ export function createVirtualElementFromEvent(
223
237
  return createVirtualElement(event.clientX, event.clientY);
224
238
  }
225
239
 
240
+ /** True when `ref` is a real DOM element rather than a Popper VirtualElement. */
241
+ export function isDomElement(ref: unknown): ref is Element {
242
+ return typeof Element !== "undefined" && ref instanceof Element;
243
+ }
244
+
226
245
  export function getScrollParent(element: HTMLElement): HTMLElement | Window {
227
246
  if (!element) return window;
228
247
 
@@ -865,7 +884,6 @@ export function createPopover(
865
884
  * 設置關閉相關的事件監聽器
866
885
  */
867
886
  function setupCloseListeners() {
868
- const referenceEl = reference as HTMLElement;
869
887
 
870
888
  if (mergedOptions.closeOnContentClick) {
871
889
  const handleContentClick = () => hide();
@@ -876,7 +894,15 @@ export function createPopover(
876
894
  }
877
895
 
878
896
  if (mergedOptions.hideOnScroll) {
879
- const scrollParents = getScrollParents(referenceEl);
897
+ // A VirtualElement has no DOM ancestry — getComputedStyle() on it
898
+ // throws. Fall back to its contextElement, else just the window.
899
+ const scrollRoot = isDomElement(reference)
900
+ ? reference
901
+ : reference.contextElement;
902
+ const scrollParents: (HTMLElement | Window)[] =
903
+ scrollRoot instanceof HTMLElement
904
+ ? getScrollParents(scrollRoot)
905
+ : [window];
880
906
  const handleScroll = () => {
881
907
  if (isVisible) hide();
882
908
  };
@@ -1165,7 +1191,10 @@ export function usePopover(
1165
1191
  const c = unref(contentRef);
1166
1192
  const r = unref(referenceRef);
1167
1193
  if (c && c.contains(target)) return true;
1168
- if (r && (r as HTMLElement).contains?.(target)) return true;
1194
+ // Only a real element can "contain" the click; a VirtualElement
1195
+ // anchor (point / range) has no DOM extent, so any click outside
1196
+ // the content counts as outside.
1197
+ if (r && isDomElement(r) && r.contains(target)) return true;
1169
1198
  return false;
1170
1199
  },
1171
1200
  close: () => instance.value?.hide(),
@@ -0,0 +1,61 @@
1
+ import { onBeforeUnmount, watch, type Ref } from "vue";
2
+
3
+ /**
4
+ * Ref-counted `<body>` scroll lock shared by every overlay that wants the
5
+ * page behind it frozen (AppModal today). Stacked modals each take a
6
+ * lock; the body is only restored when the LAST holder releases, so
7
+ * closing the top modal of two doesn't unfreeze the page under the one
8
+ * still open.
9
+ *
10
+ * While locked, `overflow: hidden` is set on <body> and the vanished
11
+ * scrollbar's width is added to its right padding so content doesn't
12
+ * jump sideways. The original inline values are restored on release.
13
+ */
14
+ let holders = 0;
15
+ let savedOverflow = "";
16
+ let savedPaddingRight = "";
17
+
18
+ function acquire() {
19
+ if (typeof document === "undefined") return;
20
+ holders++;
21
+ if (holders > 1) return;
22
+ const body = document.body;
23
+ savedOverflow = body.style.overflow;
24
+ savedPaddingRight = body.style.paddingRight;
25
+ const scrollbar =
26
+ window.innerWidth - document.documentElement.clientWidth;
27
+ if (scrollbar > 0) {
28
+ const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
29
+ body.style.paddingRight = `${current + scrollbar}px`;
30
+ }
31
+ body.style.overflow = "hidden";
32
+ }
33
+
34
+ function release() {
35
+ if (typeof document === "undefined" || holders === 0) return;
36
+ holders--;
37
+ if (holders > 0) return;
38
+ document.body.style.overflow = savedOverflow;
39
+ document.body.style.paddingRight = savedPaddingRight;
40
+ }
41
+
42
+ /**
43
+ * Hold a body scroll lock while `active` is true. Released automatically
44
+ * when `active` turns false or the owning component unmounts.
45
+ */
46
+ export function useBodyScrollLock(active: Ref<boolean>): void {
47
+ let held = false;
48
+ const set = (on: boolean) => {
49
+ if (on === held) return;
50
+ held = on;
51
+ if (on) acquire();
52
+ else release();
53
+ };
54
+ watch(active, set, { immediate: true });
55
+ onBeforeUnmount(() => set(false));
56
+ }
57
+
58
+ /** Test-only: current number of lock holders. */
59
+ export function _scrollLockHolders(): number {
60
+ return holders;
61
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,19 @@
7
7
  // import "@karyl-chan/ui/use-drawer.css";
8
8
  // import "@karyl-chan/ui/use-popover.css";
9
9
 
10
+ // ── Layout primitives ────────────────────────────────────────────────────
11
+ // Own the space BETWEEN elements so views (and the components inside them)
12
+ // don't hand-author flex+gap or per-element margins. See lib/space.ts for
13
+ // the `gap` scale.
14
+ export { default as Stack } from "./components/Stack.vue";
15
+ export { default as Cluster } from "./components/Cluster.vue";
16
+ export { default as Spacer } from "./components/Spacer.vue";
17
+ export {
18
+ resolveSpace,
19
+ type Align,
20
+ type Justify,
21
+ } from "./lib/space";
22
+
10
23
  // ── Components ───────────────────────────────────────────────────────────
11
24
  export { default as AppBadge } from "./components/AppBadge.vue";
12
25
  export type { BadgeSize, BadgeTone, BadgeVariant } from "./components/AppBadge.vue";
@@ -18,6 +31,8 @@ export { default as AppMenu } from "./components/AppMenu.vue";
18
31
  export { default as AppMenuItem } from "./components/AppMenuItem.vue";
19
32
  export { default as AppModal } from "./components/AppModal.vue";
20
33
  export { default as AppPopover } from "./components/AppPopover.vue";
34
+ export { default as AppProgress } from "./components/AppProgress.vue";
35
+ export type { ProgressSize, ProgressTone } from "./components/AppProgress.vue";
21
36
  export { default as AppSelect } from "./components/AppSelect.vue";
22
37
  export { default as AppSelectField } from "./components/AppSelectField.vue";
23
38
  export type { SelectOption } from "./components/AppSelectField.vue";
@@ -66,10 +81,18 @@ export { useEscapeStack } from "./composables/use-escape-stack";
66
81
  export { useFileDrop } from "./composables/use-file-drop";
67
82
  export type { FileDropHandlers } from "./composables/use-file-drop";
68
83
  export { useLongPress } from "./composables/use-long-press";
69
- export { usePopover } from "./composables/use-popover";
70
- export type { Placement, UsePopoverOptions } from "./composables/use-popover";
84
+ export {
85
+ usePopover,
86
+ createVirtualElement,
87
+ createVirtualElementFromEvent,
88
+ } from "./composables/use-popover";
89
+ export type {
90
+ Placement,
91
+ UsePopoverOptions,
92
+ VirtualElement,
93
+ } from "./composables/use-popover";
71
94
  export { useShiftKey } from "./composables/use-shift-key";
72
95
 
73
96
  // ── Stores ───────────────────────────────────────────────────────────────
74
- export { useToastStore } from "./stores/toastStore";
75
- export type { ToastItem } from "./stores/toastStore";
97
+ export { useToastStore, DEFAULT_TOAST_DURATION } from "./stores/toastStore";
98
+ export type { ToastItem, ToastType, ToastOptions } from "./stores/toastStore";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Focus helpers for dialog-style overlays (focus-in on open, Tab trap,
3
+ * focus restore on close).
4
+ */
5
+
6
+ const FOCUSABLE_SELECTOR = [
7
+ "a[href]",
8
+ "area[href]",
9
+ "button:not([disabled])",
10
+ 'input:not([disabled]):not([type="hidden"])',
11
+ "select:not([disabled])",
12
+ "textarea:not([disabled])",
13
+ "iframe",
14
+ "audio[controls]",
15
+ "video[controls]",
16
+ "summary",
17
+ '[contenteditable]:not([contenteditable="false"])',
18
+ "[tabindex]",
19
+ ].join(",");
20
+
21
+ function isVisible(el: HTMLElement): boolean {
22
+ if (el.closest("[hidden], [inert]")) return false;
23
+ // `checkVisibility` covers display:none / visibility:hidden ancestors.
24
+ // Environments without it (older engines, jsdom) count it as visible.
25
+ const check = (el as HTMLElement & { checkVisibility?: () => boolean })
26
+ .checkVisibility;
27
+ return typeof check === "function" ? check.call(el) : true;
28
+ }
29
+
30
+ /** Tabbable descendants of `container`, in DOM order. */
31
+ export function getFocusable(container: HTMLElement): HTMLElement[] {
32
+ return Array.from(
33
+ container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
34
+ ).filter((el) => el.tabIndex >= 0 && isVisible(el));
35
+ }
36
+
37
+ /**
38
+ * Keep Tab / Shift+Tab cycling inside `container`. Call from the
39
+ * container's keydown handler; returns true when it handled the key.
40
+ */
41
+ export function trapTabKey(event: KeyboardEvent, container: HTMLElement): boolean {
42
+ if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey) {
43
+ return false;
44
+ }
45
+ const items = getFocusable(container);
46
+ if (items.length === 0) {
47
+ event.preventDefault();
48
+ container.focus();
49
+ return true;
50
+ }
51
+ const first = items[0];
52
+ const last = items[items.length - 1];
53
+ const active = document.activeElement as HTMLElement | null;
54
+ const inside = !!active && container.contains(active);
55
+ if (event.shiftKey) {
56
+ if (!inside || active === first || active === container) {
57
+ event.preventDefault();
58
+ last.focus();
59
+ return true;
60
+ }
61
+ } else if (!inside || active === last) {
62
+ event.preventDefault();
63
+ first.focus();
64
+ return true;
65
+ }
66
+ return false;
67
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Spacing helpers for the layout primitives (Stack / Cluster / Spacer).
3
+ *
4
+ * `gap` and friends take a spacing-scale STEP (0–8, as a number or
5
+ * string) which maps to the corresponding `--space-*` token defined in
6
+ * tokens.css, or a raw CSS length (e.g. `"2px"`, `"1.5rem"`) as an
7
+ * escape hatch. Keeping the scale in tokens.css means the whole app
8
+ * shares one rhythm instead of 200 hand-picked gap values.
9
+ */
10
+
11
+ /** Steps that exist as `--space-N` tokens in tokens.css. */
12
+ const STEPS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8"]);
13
+
14
+ /** Resolve a gap/space prop to a CSS length: a scale step → `var(--space-N)`,
15
+ * anything else → the value verbatim (raw CSS length escape hatch). */
16
+ export function resolveSpace(
17
+ value: string | number | undefined,
18
+ fallback = "0",
19
+ ): string {
20
+ if (value === undefined || value === null || value === "") return fallback;
21
+ const s = String(value);
22
+ return STEPS.has(s) ? `var(--space-${s})` : s;
23
+ }
24
+
25
+ export type Align = "start" | "center" | "end" | "stretch" | "baseline";
26
+ export type Justify = "start" | "center" | "end" | "between" | "around";
27
+
28
+ /** Friendly `align` prop → CSS `align-items` value. */
29
+ export const ALIGN_ITEMS: Record<Align, string> = {
30
+ start: "flex-start",
31
+ center: "center",
32
+ end: "flex-end",
33
+ stretch: "stretch",
34
+ baseline: "baseline",
35
+ };
36
+
37
+ /** Friendly `justify` prop → CSS `justify-content` value. */
38
+ export const JUSTIFY_CONTENT: Record<Justify, string> = {
39
+ start: "flex-start",
40
+ center: "center",
41
+ end: "flex-end",
42
+ between: "space-between",
43
+ around: "space-around",
44
+ };
@@ -1,24 +1,62 @@
1
1
  import { defineStore } from "pinia";
2
2
  import { ref } from "vue";
3
3
 
4
+ /**
5
+ * Toast severity. `error` / `warn` are announced assertively
6
+ * (role="alert"); `info` / `success` politely (role="status").
7
+ */
8
+ export type ToastType = "error" | "warn" | "info" | "success";
9
+
10
+ export interface ToastOptions {
11
+ /**
12
+ * Auto-dismiss delay in ms. Default 4000. `0` makes the toast sticky:
13
+ * it stays until the user closes it (close button) or `dismiss(id)`.
14
+ */
15
+ duration?: number;
16
+ }
17
+
4
18
  export interface ToastItem {
5
19
  id: number;
6
20
  message: string;
7
- type: "error" | "info";
21
+ type: ToastType;
22
+ /** Auto-dismiss delay in ms; `0` = sticky. Always set by `show()`. */
23
+ duration?: number;
8
24
  }
9
25
 
26
+ /** Default auto-dismiss delay, unchanged from the original store. */
27
+ export const DEFAULT_TOAST_DURATION = 4000;
28
+
10
29
  let nextId = 0;
11
30
 
12
31
  export const useToastStore = defineStore("toast", () => {
13
32
  const items = ref<ToastItem[]>([]);
33
+ const timers = new Map<number, ReturnType<typeof setTimeout>>();
14
34
 
15
- function show(message: string, type: ToastItem["type"] = "error") {
35
+ /**
36
+ * Show a toast. `type` defaults to `'error'` (the original behaviour).
37
+ * Returns the toast id, usable with `dismiss(id)`.
38
+ */
39
+ function show(
40
+ message: string,
41
+ type: ToastType = "error",
42
+ options: ToastOptions = {},
43
+ ): number {
16
44
  const id = nextId++;
17
- items.value = [...items.value, { id, message, type }];
18
- setTimeout(() => dismiss(id), 4000);
45
+ const raw = options.duration ?? DEFAULT_TOAST_DURATION;
46
+ const duration = Number.isFinite(raw) && raw > 0 ? raw : 0;
47
+ items.value = [...items.value, { id, message, type, duration }];
48
+ if (duration > 0) {
49
+ timers.set(id, setTimeout(() => dismiss(id), duration));
50
+ }
51
+ return id;
19
52
  }
20
53
 
21
54
  function dismiss(id: number) {
55
+ const timer = timers.get(id);
56
+ if (timer !== undefined) {
57
+ clearTimeout(timer);
58
+ timers.delete(id);
59
+ }
22
60
  items.value = items.value.filter((t) => t.id !== id);
23
61
  }
24
62
 
package/src/tokens.css CHANGED
@@ -57,6 +57,12 @@
57
57
 
58
58
  --pill-bg: #f3f4f6;
59
59
 
60
+ /* Unread / mention indicator (UnreadPill etc.). Same in both themes. */
61
+ --unread-accent: #f23f43;
62
+
63
+ /* Monospaced stack for IDs, keys, code-ish values. */
64
+ --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
65
+
60
66
  --transition-fast: 0.12s;
61
67
  --transition-base: 0.15s;
62
68
  --transition-slow: 0.2s;
@@ -66,6 +72,21 @@
66
72
  --radius-lg: 8px;
67
73
  --radius-xl: 12px;
68
74
  --radius-pill: 999px;
75
+
76
+ /* Spacing scale — the single source of truth for gaps between
77
+ * elements. Consume via the <Stack> / <Cluster> layout primitives
78
+ * (gap="4") rather than hand-authoring margins/gaps per view, so
79
+ * inter-element rhythm is owned by the layout layer, not by each
80
+ * component. Theme-independent (spacing doesn't change light/dark). */
81
+ --space-0: 0;
82
+ --space-1: 0.25rem;
83
+ --space-2: 0.5rem;
84
+ --space-3: 0.75rem;
85
+ --space-4: 1rem;
86
+ --space-5: 1.5rem;
87
+ --space-6: 2rem;
88
+ --space-7: 3rem;
89
+ --space-8: 4rem;
69
90
  }
70
91
 
71
92
  /* Pin the UA-side color-scheme hint so native widgets (form controls,