@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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,147 @@
1
+ /**
2
+ * PageTemplate: the screen a Pathway page is built on.
3
+ *
4
+ * Figma `ScreenTemplate`, set 40009709:26016, page "↳ ❇️ Page Template".
5
+ *
6
+ * TWO NESTED SURFACES, and the distinction is the whole component:
7
+ *
8
+ * Container.Screen the CANVAS behind everything. Fill/Surface/Canvas,
9
+ * padded by SheetContainer/Padding/*, no radius.
10
+ * └─ Container.Screen.Main the SHEET the page actually sits on.
11
+ * Fill/Surface/Elevated, padded by Sheet/Padding/*,
12
+ * rounded at the TOP ONLY, with Shadow/Sheet.
13
+ * ├─ Slot.PageNavigation optional, holds Tabs in Figma's example
14
+ * └─ Container.ScreenContent the page: heading, toolbar, sections
15
+ *
16
+ * IT TAKES NO BREAKPOINT PROP, and that is not an omission. Figma models the
17
+ * three breakpoints as variants because Figma has no media queries. Every value
18
+ * that differs between them is already a `Responsive: Layout` token, and those
19
+ * tokens are emitted into media queries by the token pipeline. Read off both
20
+ * sides on 2026-10-12, they agree exactly:
21
+ *
22
+ * SheetContainer pad Sheet pad (top/h/bottom) radius
23
+ * Desktop 16 / 24 16 / 32 / 56 28
24
+ * Tablet 12 / 16 14 / 24 / 48 24
25
+ * Mobile 8 / 12 8 / 16 / 32 20
26
+ *
27
+ * So the component reads the tokens and the browser picks the breakpoint. A
28
+ * `breakpoint` prop here would be a second source of truth for values the CSS
29
+ * already has, and it would be wrong the moment someone resized a window.
30
+ *
31
+ * WHY THIS EXISTS AT ALL. A widget on a dashboard is not understandable without
32
+ * the page around it: the grid's 12 columns are 12 columns OF SOMETHING, and
33
+ * that something is this sheet's content box, which is the viewport minus two
34
+ * levels of padding that both change with the breakpoint. Demonstrating a board
35
+ * without it shows widgets spanning a container nobody can see.
36
+ */
37
+ import React from "react";
38
+
39
+ const R = (n) => `var(--responsive-layout-${n})`;
40
+ const U = (n) => `var(--semantic-layout-units-${n})`;
41
+ const C = (n) => `var(--semantic-color-${n})`;
42
+
43
+ export const T = {
44
+ canvas: C("fill-surface-canvas"),
45
+ sheet: C("fill-surface-elevated"),
46
+ divider: C("stroke-static-neutral-faint"),
47
+ };
48
+
49
+ export const L = {
50
+ // The canvas. Bottom is 0 in every variant: the sheet runs to the edge.
51
+ screenPadH: R("sheetcontainer-padding-horizontal"),
52
+ screenPadT: R("sheetcontainer-padding-top"),
53
+ // The sheet.
54
+ sheetPadH: R("sheet-padding-horizontal"),
55
+ sheetPadT: R("sheet-padding-top"),
56
+ sheetPadB: R("sheet-padding-bottom"),
57
+ sheetRadius: R("sheet-corner-radius-cornerradius"),
58
+ // Gaps: the sheet's own children, then the page content's sections.
59
+ sheetGap: U("gap-tight"), // 8
60
+ contentGap: U("gap-base"), // 16
61
+ navHeight: 49,
62
+ };
63
+
64
+ /**
65
+ * @param {node} children Container.ScreenContent: the page itself.
66
+ * @param {node} navigation Slot.PageNavigation. Figma's `Show PageTopNavigation`
67
+ * boolean is simply whether this is passed.
68
+ * @param {bool} grid Draws the 12/8/4 column guides over the content, so a
69
+ * dashboard demo can show what its spans are spans OF.
70
+ */
71
+ export function PageTemplate({ children, navigation, grid = false, className = "", style }) {
72
+ return (
73
+ <div className={`pw-screen ${className}`} style={{
74
+ background: T.canvas, minHeight: "100%", boxSizing: "border-box",
75
+ paddingLeft: L.screenPadH, paddingRight: L.screenPadH,
76
+ paddingTop: L.screenPadT, paddingBottom: 0,
77
+ ...style,
78
+ }}>
79
+ <div className="pw-screen-main" style={{
80
+ display: "flex", flexDirection: "column", gap: L.sheetGap,
81
+ background: T.sheet, boxSizing: "border-box",
82
+ paddingLeft: L.sheetPadH, paddingRight: L.sheetPadH,
83
+ paddingTop: L.sheetPadT, paddingBottom: L.sheetPadB,
84
+ /* TOP CORNERS ONLY. The sheet's bottom edge meets the bottom of the
85
+ viewport, so rounding it would put a visible notch there. Same shape
86
+ as the Widget's inner card, for the same reason. */
87
+ borderTopLeftRadius: L.sheetRadius,
88
+ borderTopRightRadius: L.sheetRadius,
89
+ boxShadow: "var(--elevation-sheet)",
90
+ minHeight: "100%",
91
+ }}>
92
+ {navigation ? (
93
+ <div className="pw-screen-nav" style={{
94
+ flex: "0 0 auto", minHeight: L.navHeight, boxSizing: "border-box",
95
+ borderBottom: `1px solid ${T.divider}`,
96
+ }}>{navigation}</div>
97
+ ) : null}
98
+
99
+ <div className="pw-screen-content" style={{
100
+ display: "flex", flexDirection: "column", gap: L.contentGap,
101
+ flex: 1, minHeight: 0, position: "relative",
102
+ }}>
103
+ {grid ? <ColumnGuides /> : null}
104
+ {children}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The column guides, drawn behind the content. Not part of Figma's component:
113
+ * this is a teaching aid, which is why it is off by default and why it is
114
+ * `aria-hidden`. It exists because the board's spans are meaningless without
115
+ * something to count them against.
116
+ */
117
+ export function ColumnGuides() {
118
+ return (
119
+ <div aria-hidden="true" className="pw-screen-guides" style={{
120
+ position: "absolute", inset: 0, pointerEvents: "none",
121
+ display: "grid", gap: U("gap-base"),
122
+ }}>
123
+ {Array.from({ length: 12 }).map((_, i) => (
124
+ <span key={i} style={{
125
+ gridRow: 1,
126
+ background: C("fill-action-primary-subtle-rest"),
127
+ opacity: 0.35,
128
+ }} />
129
+ ))}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /** The breakpoint rules the guides need, since a count cannot be a token. */
135
+ export const PageTemplateStyles = () => (
136
+ <style>{`
137
+ .pw-screen-guides { grid-template-columns: repeat(12, minmax(0, 1fr)); }
138
+ @media (max-width: 1023px) {
139
+ .pw-screen-guides { grid-template-columns: repeat(8, minmax(0, 1fr)); }
140
+ }
141
+ @media (max-width: 767px) {
142
+ .pw-screen-guides { grid-template-columns: repeat(4, minmax(0, 1fr)); }
143
+ }
144
+ `}</style>
145
+ );
146
+
147
+ export default PageTemplate;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Scrollable, Pathway Design System
3
+ *
4
+ * A reusable overlay-scrollbar wrapper. Hides the native OS scrollbar entirely and renders
5
+ * one custom liquid-glass thumb so scrolling looks and behaves IDENTICALLY on macOS, Windows,
6
+ * iOS, and Android (native scrollbars can't be made consistent: this sidesteps them).
7
+ *
8
+ * Behaviour (see components/scrollbar/scrollbar-spec.md):
9
+ * - Thumb height is PROPORTIONAL to content: (viewport / total) × track, floored at 28px.
10
+ * - Reveal: appears while scrolling, fades ~900ms after; also appears when the MOUSE is near
11
+ * the right-edge bar (so it can be grabbed). It does NOT appear from hovering the panel.
12
+ * - Drag: MOUSE only, via a wider invisible grab strip (the 6px thumb alone is hard to hit).
13
+ * - Touch: passive: the bar just shows position; you scroll by swiping content. The grab
14
+ * strip is disabled on touch devices via a `(hover: none)` rule.
15
+ * - Keyboard / a11y: the content scrolls natively (arrows, PageUp/Down, Home/End, Space); the
16
+ * thumb is decorative (`aria-hidden`) and never the only scroll mechanism.
17
+ *
18
+ * Usage:
19
+ * <Scrollable style={{ flex: 1, minHeight: 0 }}>…tall content…</Scrollable>
20
+ */
21
+
22
+ import React, { useRef, useState, useEffect, useCallback } from "react";
23
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
24
+
25
+ // ─── TOKENS ──────────────────────────────────────────────────────────────────
26
+ // Every colour + unit resolves through a SEMANTIC Pathway token: no primitives,
27
+ // no hardcoded hex/px. Motion uses the --motion-* tokens (durations/easings from design-system-spec §2).
28
+ export const SCROLL = {
29
+ thumbWidth: "var(--semantic-layout-units-padding-xtight)", // 6px: visible thumb thickness
30
+ thumbRadius: "var(--semantic-layout-units-cornerradius-full)", // fully-rounded pill
31
+ thumbMin: 28, // px, min thumb length (grab-target floor); JS layout math
32
+ gutter: "var(--semantic-layout-units-padding-xxxtight)", // 2px, inset from the right edge
33
+ grabZone: 16, // px, invisible mouse grab strip (wider than the 6px thumb so it's catchable)
34
+ // THE LIGHT RUNGS ARE A DELIBERATE CHOICE BY THE DESIGN OWNER. Do not raise
35
+ // them again without asking Jo first.
36
+ //
37
+ // These were briefly Subtle (50%) and Base (70%) to clear the 3:1 AA floor
38
+ // for a non-text UI component, after one person reported the scrollbar as
39
+ // missing while another saw it fine. Measured over #fafafa: Faint 16% =
40
+ // 1.4:1, Light 24% = 1.68:1, Subtle 50% = 3.4:1, Base 70% = 6.57:1.
41
+ //
42
+ // Jo reviewed that in Storybook and rejected it: the heavier thumb reads as
43
+ // a solid grey bar rather than the light warm overlay the design intends, and
44
+ // an overlay scrollbar is a redundant affordance (the content scrolls with or
45
+ // without it, and the OS bar remains). So contrast is bought back with SIZE
46
+ // and PRESENCE rather than with darkness: see thumbWidth and the grab strip.
47
+ // Scrim/Faint and Scrim/Light resolve to Cool Neutral 800, so the thumb was
48
+ // drawing a COOL grey over warm surfaces and reading as a cold bar against
49
+ // them. Reported 2026-10-02. The comment below has always said "light warm
50
+ // overlay": the value simply did not match it.
51
+ //
52
+ // Scrim is also the wrong family: it is the modal backdrop, where a cool
53
+ // near-black is right. The thumb is an overlay on a surface, and under the
54
+ // alpha rule surfaces and fills are WARM. So the thumb now resolves through
55
+ // its own themed custom properties, set in the injected sheet below off the
56
+ // warm neutral alpha primitives. No new design token: a component-local
57
+ // property, the same mechanism the Widget uses for its column spans.
58
+ thumbRest: "var(--pw-scrollbar-thumb-rest)", // warm neutral 16%
59
+ thumbHover: "var(--pw-scrollbar-thumb-hover)", // warm neutral 24%
60
+ thumbBlur: "blur(8px) saturate(180%)",
61
+ // Hairline glass edge: semantic white at 35% via color-mix.
62
+ thumbEdge: "inset 0 0 0 0.5px color-mix(in srgb, var(--semantic-color-fill-static-neutral-faint) 35%, transparent)",
63
+ // Fade OUT gracefully, but appear INSTANTLY. Animating opacity on the way in
64
+ // multiplied the thumb's own alpha by the in-flight opacity, so during the
65
+ // 200ms glide-in it rendered as low as 1.18:1, below the floor at the exact
66
+ // moment the user is looking for it. Only the fade-out is animated now.
67
+ // Asymmetric fade: snappy decelerate glide-IN, graceful fade-OUT. Each entry
68
+ // names every property it is responsible for, because the element swaps
69
+ // between them with `transition: active ? fadeIn : fadeOut`.
70
+ //
71
+ // fadeIn briefly listed only `background`, on the reasoning that animating
72
+ // opacity multiplies the thumb's own alpha by the in-flight opacity and dips
73
+ // the contrast mid-glide. The visible result was worse than the thing it
74
+ // avoided: the thumb POPPED into existence the instant you scrolled. The
75
+ // glide is the point, so opacity is back.
76
+ fadeIn: "opacity var(--motion-duration-3) var(--motion-easing-decelerate), background var(--motion-duration-3) var(--motion-easing-standard)",
77
+ fadeOut: "opacity var(--motion-duration-5) var(--motion-easing-standard), background var(--motion-duration-3) var(--motion-easing-standard)",
78
+ idleHideMs: 500, // hide the thumb this long after scrolling stops / mouse leaves the bar
79
+ };
80
+
81
+ // Injected once: hide the native bar on the opt-in class, and disable the mouse grab strip on
82
+ // touch devices so finger-swipes scroll the content underneath (the thumb stays a passive indicator).
83
+ if (typeof document !== "undefined" && !document.getElementById("pds-scrollable-base")) {
84
+ const s = document.createElement("style");
85
+ s.id = "pds-scrollable-base";
86
+ s.textContent =
87
+ // Warm neutral, and it has to INVERT: 500 is the dark warm grey for a light
88
+ // surface, 100 the light warm grey for a dark one. An inline style cannot
89
+ // branch on [data-theme], which is why these live here rather than in the
90
+ // SCROLL map above.
91
+ ":root,[data-theme=\"light\"]{" +
92
+ "--pw-scrollbar-thumb-rest:var(--primitive-color-warm-neutral-500-16);" +
93
+ "--pw-scrollbar-thumb-hover:var(--primitive-color-warm-neutral-500-24)}" +
94
+ "[data-theme=\"midnight\"],[data-theme=\"dark\"]{" +
95
+ "--pw-scrollbar-thumb-rest:var(--primitive-color-warm-neutral-100-16);" +
96
+ "--pw-scrollbar-thumb-hover:var(--primitive-color-warm-neutral-100-24)}" +
97
+ ".pds-scrollable__view{scrollbar-width:none;-ms-overflow-style:none}" +
98
+ ".pds-scrollable__view:-webkit-scrollbar{width:0;height:0;display:none}" +
99
+ "@media (hover: none){.pds-scrollable__grab{pointer-events:none!important}}";
100
+ document.head.appendChild(s);
101
+ }
102
+
103
+ export function Scrollable({ children, className = "", style = {}, viewClassName = "", viewStyle = {} }) {
104
+ const noMotion = useReducedMotion();
105
+ const viewRef = useRef(null);
106
+ const dragRef = useRef(null);
107
+ const idleRef = useRef(null);
108
+ const nearRef = useRef(false); // mouse currently near the right-edge bar
109
+ const [thumb, setThumb] = useState({ h: 0, top: 0, show: false });
110
+ const [active, setActive] = useState(false); // thumb visible (scrolling, near-bar, or dragging)
111
+ const [hot, setHot] = useState(false); // mouse over / dragging the grab strip
112
+
113
+ // Thumb height is PROPORTIONAL to content; floored at thumbMin. Position maps the floored
114
+ // thumb across the full track so it still reaches top and bottom exactly.
115
+ const recompute = useCallback(() => {
116
+ const el = viewRef.current;
117
+ if (!el) return;
118
+ const { scrollHeight, clientHeight, scrollTop } = el;
119
+ if (scrollHeight <= clientHeight + 1) { setThumb(t => (t.show ? { ...t, show: false } : t)); return; }
120
+ const h = Math.max(SCROLL.thumbMin, (clientHeight / scrollHeight) * clientHeight);
121
+ const top = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
122
+ setThumb({ h, top, show: true });
123
+ }, []);
124
+
125
+ useEffect(() => {
126
+ recompute();
127
+ const el = viewRef.current;
128
+ if (!el || typeof ResizeObserver === "undefined") return;
129
+ const ro = new ResizeObserver(recompute);
130
+ ro.observe(el);
131
+ Array.from(el.children).forEach(c => ro.observe(c));
132
+ return () => ro.disconnect();
133
+ }, [children, recompute]);
134
+
135
+ const scheduleHide = () => {
136
+ clearTimeout(idleRef.current);
137
+ idleRef.current = setTimeout(() => {
138
+ if (!dragRef.current && !nearRef.current) setActive(false);
139
+ }, SCROLL.idleHideMs);
140
+ };
141
+ const wake = () => { setActive(true); scheduleHide(); }; // reveal while scrolling
142
+ const onScroll = () => { recompute(); wake(); };
143
+
144
+ // Edge-proximity reveal (mouse): show the bar when the cursor is within grabZone of the right
145
+ // edge, so it can be grabbed: but NOT from hovering the rest of the panel.
146
+ const onMouseMove = (e) => {
147
+ const rect = e.currentTarget.getBoundingClientRect();
148
+ if (rect.right - e.clientX <= SCROLL.grabZone) {
149
+ nearRef.current = true; setActive(true); clearTimeout(idleRef.current);
150
+ } else if (nearRef.current) {
151
+ nearRef.current = false; scheduleHide();
152
+ }
153
+ };
154
+ const onMouseLeave = () => { nearRef.current = false; if (!dragRef.current) scheduleHide(); };
155
+
156
+ // Drag (mouse only). The grab strip carries this; the visual thumb is decorative.
157
+ const onGrabDown = (e) => {
158
+ e.preventDefault();
159
+ const el = viewRef.current;
160
+ if (!el) return;
161
+ dragRef.current = { startY: e.clientY, startTop: el.scrollTop };
162
+ setHot(true); setActive(true); clearTimeout(idleRef.current);
163
+ const onMove = (ev) => {
164
+ const { scrollHeight, clientHeight } = el;
165
+ const h = Math.max(SCROLL.thumbMin, (clientHeight / scrollHeight) * clientHeight);
166
+ const ratio = (scrollHeight - clientHeight) / (clientHeight - h);
167
+ el.scrollTop = dragRef.current.startTop + (ev.clientY - dragRef.current.startY) * ratio;
168
+ };
169
+ const onUp = () => {
170
+ dragRef.current = null; setHot(false);
171
+ document.removeEventListener("mousemove", onMove);
172
+ document.removeEventListener("mouseup", onUp);
173
+ wake();
174
+ };
175
+ document.addEventListener("mousemove", onMove);
176
+ document.addEventListener("mouseup", onUp);
177
+ };
178
+
179
+ const barTop = thumb.top, barH = thumb.h;
180
+
181
+ return (
182
+ <div
183
+ className={`pds-scrollable ${className}`}
184
+ style={{ position: "relative", minHeight: 0, ...style }}
185
+ onMouseMove={onMouseMove}
186
+ onMouseLeave={onMouseLeave}
187
+ >
188
+ <div
189
+ ref={viewRef}
190
+ className={`pds-scrollable__view ${viewClassName}`}
191
+ onScroll={onScroll}
192
+ style={{ height: "100%", overflowY: "auto", overflowX: "hidden", ...viewStyle }}
193
+ >
194
+ {children}
195
+ </div>
196
+
197
+ {thumb.show && (
198
+ <>
199
+ {/* Visual thumb, decorative, never intercepts pointer events. */}
200
+ <div
201
+ aria-hidden="true"
202
+ style={{
203
+ position: "absolute", top: barTop, right: SCROLL.gutter,
204
+ width: SCROLL.thumbWidth, height: barH, borderRadius: SCROLL.thumbRadius,
205
+ background: hot ? SCROLL.thumbHover : SCROLL.thumbRest,
206
+ backdropFilter: SCROLL.thumbBlur, WebkitBackdropFilter: SCROLL.thumbBlur,
207
+ boxShadow: SCROLL.thumbEdge,
208
+ opacity: active ? 1 : 0, transition: noMotion ? "none" : (active ? SCROLL.fadeIn : SCROLL.fadeOut),
209
+ pointerEvents: "none", zIndex: 5,
210
+ }}
211
+ />
212
+ {/* Invisible mouse grab strip, wider than the thumb so it's catchable; disabled on
213
+ touch via the (hover:none) rule so finger-swipes scroll the content beneath. */}
214
+ <div
215
+ aria-hidden="true"
216
+ className="pds-scrollable__grab"
217
+ onMouseDown={onGrabDown}
218
+ onMouseEnter={() => setHot(true)}
219
+ onMouseLeave={() => setHot(false)}
220
+ style={{
221
+ position: "absolute", top: barTop, right: 0,
222
+ width: SCROLL.grabZone, height: barH,
223
+ pointerEvents: active ? "auto" : "none", zIndex: 6, background: "transparent",
224
+ }}
225
+ />
226
+ </>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ export default Scrollable;