@lynx-example/design-guide 0.1.0 → 0.2.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,20 @@
1
+ type Cell = { id: string; i: number; j: number; cx: number; cy: number };
2
+
3
+ const cols = 20;
4
+ const rows = 20;
5
+ const CELLS: Cell[] = [];
6
+
7
+ for (let c = 0; c < cols; c++) {
8
+ for (let r = 0; r < rows; r++) {
9
+ CELLS.push({
10
+ id: `${r}-${c}`,
11
+ i: r,
12
+ j: c,
13
+ // normalized cell center in [0,1]
14
+ cx: (c + 0.5) / cols,
15
+ cy: (r + 0.5) / rows,
16
+ });
17
+ }
18
+ }
19
+
20
+ export { CELLS };
@@ -0,0 +1,58 @@
1
+ import { clamp01 } from "./math.js";
2
+
3
+ function lerpColor(
4
+ t: number,
5
+ colorA: string,
6
+ colorB: string,
7
+ ): string {
8
+ const [rA, gA, bA] = hexToRgb(colorA);
9
+ const [rB, gB, bB] = hexToRgb(colorB);
10
+
11
+ const r = mix(rA, rB, t);
12
+ const g = mix(gA, gB, t);
13
+ const b = mix(bA, bB, t);
14
+
15
+ return rgbToHex(r, g, b);
16
+ }
17
+
18
+ function hexToRgb(hex: string): [number, number, number] {
19
+ const n = hex.replace("#", "");
20
+ return [
21
+ parseInt(n.slice(0, 2), 16),
22
+ parseInt(n.slice(2, 4), 16),
23
+ parseInt(n.slice(4, 6), 16),
24
+ ];
25
+ }
26
+
27
+ function rgbToHex(r: number, g: number, b: number): string {
28
+ return (
29
+ "#"
30
+ + [r, g, b]
31
+ .map(v => Math.round(v).toString(16).padStart(2, "0"))
32
+ .join("")
33
+ );
34
+ }
35
+
36
+ function mix(a: number, b: number, t: number) {
37
+ return a * (1 - t) + b * t;
38
+ }
39
+
40
+ function lerpColor4(
41
+ t: number,
42
+ c0: string,
43
+ c1: string,
44
+ c2: string,
45
+ c3: string,
46
+ ): string {
47
+ const x = clamp01(t);
48
+
49
+ if (x < 1 / 3) {
50
+ return lerpColor(x / (1 / 3), c0, c1);
51
+ }
52
+ if (x < 2 / 3) {
53
+ return lerpColor((x - 1 / 3) / (1 / 3), c1, c2);
54
+ }
55
+ return lerpColor((x - 2 / 3) / (1 / 3), c2, c3);
56
+ }
57
+
58
+ export { lerpColor, lerpColor4 };
@@ -0,0 +1,97 @@
1
+ import { smoothstep01 } from "./math.js";
2
+ export type Vec2 = { x: number; y: number };
3
+
4
+ export type ForceFieldOptions = {
5
+ // Core shape
6
+ /**
7
+ * influence radius in normalized space (0..sqrt(2))
8
+ */
9
+ radius?: number;
10
+ /**
11
+ * overall displacement magnitude (normalized units)
12
+ */
13
+ strength?: number;
14
+ /**
15
+ * higher = faster decay
16
+ */
17
+ falloff?: number;
18
+ mode?: "repel" | "attract";
19
+
20
+ // Optional style
21
+ /**
22
+ * tangential component, gives "vortex" feel
23
+ */
24
+ swirl?: number;
25
+ /**
26
+ * avoids singularity near center
27
+ */
28
+ soften?: number;
29
+ /**
30
+ * max displacement magnitude
31
+ */
32
+ clampMax?: number;
33
+ };
34
+
35
+ const DEFAULTS: Required<ForceFieldOptions> = {
36
+ radius: 0.45,
37
+ strength: 0.085,
38
+ falloff: 2.2,
39
+ mode: "repel",
40
+ swirl: 0.0,
41
+ soften: 0.02,
42
+ clampMax: 0.12,
43
+ };
44
+
45
+ export function makeForceField(user: ForceFieldOptions = {}) {
46
+ const opt = { ...DEFAULTS, ...user };
47
+
48
+ return function forceAt(cell: Vec2, p: Vec2) {
49
+ // Vector from p -> cell
50
+ const vx = cell.x - p.x;
51
+ const vy = cell.y - p.y;
52
+ const d = Math.sqrt(vx * vx + vy * vy);
53
+
54
+ // Outside radius: no force
55
+ if (d >= opt.radius) return { dx: 0, dy: 0, t: 1, d };
56
+
57
+ // Normalize direction (avoid NaN at center)
58
+ const inv = 1 / Math.max(d, opt.soften);
59
+ const nx = vx * inv;
60
+ const ny = vy * inv;
61
+
62
+ // 0..1 influence inside radius (1 at center, 0 at edge)
63
+ const u = 1 - d / opt.radius;
64
+
65
+ // Smooth & decay
66
+ const influence = smoothstep01(u);
67
+ const decay = Math.pow(influence, opt.falloff);
68
+
69
+ // Base radial magnitude
70
+ let mag = opt.strength * decay;
71
+
72
+ // Repel vs attract
73
+ if (opt.mode === "attract") mag = -mag;
74
+
75
+ // Add optional swirl (tangential)
76
+ // Tangent is perpendicular to radial: ( -ny, nx )
77
+ const tx = -ny;
78
+ const ty = nx;
79
+
80
+ let dx = nx * mag + tx * (opt.swirl * mag);
81
+ let dy = ny * mag + ty * (opt.swirl * mag);
82
+
83
+ // Clamp displacement magnitude for stability
84
+ const m = Math.sqrt(dx * dx + dy * dy);
85
+ if (m > opt.clampMax) {
86
+ const s = opt.clampMax / m;
87
+ dx *= s;
88
+ dy *= s;
89
+ }
90
+
91
+ // t: a handy 0..1 "field value" you can reuse for color/size
92
+ // Here we invert so t=0 near center, t=1 far away.
93
+ const t = 1 - decay;
94
+
95
+ return { dx, dy, t, d };
96
+ };
97
+ }
@@ -0,0 +1,23 @@
1
+ .dot-field {
2
+ position: relative;
3
+ --dot-size: 5px;
4
+ --dot-color: #f8f8f8;
5
+ --dot-accent-color: #ffff00;
6
+ --field-size: 300px;
7
+ width: var(--field-size);
8
+ height: var(--field-size);
9
+ }
10
+
11
+ .dot {
12
+ width: calc(var(--dot-size) * var(--s, 1));
13
+ height: calc(var(--dot-size) * var(--s, 1));
14
+ transform: translate(
15
+ calc(var(--field-size) * var(--x, 0)),
16
+ calc(var(--field-size) * var(--y, 0))
17
+ );
18
+ background-color: var(--dot-color, #f8f8f8);
19
+ border-radius: 9999px;
20
+ position: absolute;
21
+ top: 0;
22
+ left: 0;
23
+ }
@@ -0,0 +1,55 @@
1
+ import type { DotFieldProps, DotProps } from "./field.types.js";
2
+
3
+ import "./field.css";
4
+
5
+ /**
6
+ * Dot consumes normalized, unitless styling parameters.
7
+ * Concrete layout and sizing are resolved against field-level
8
+ * CSS variables, rather than computed imperatively in JS.
9
+ */
10
+ function Dot({ x = 0, y = 0, s = 1, color, useAccent }: DotProps) {
11
+ return (
12
+ <view
13
+ className="dot"
14
+ // Inline CSS var reference: route to a field-scoped CSS token (`--dot-accent-color`)
15
+ // instead of resolving the color imperatively in JS.
16
+ style={`--x:${x}; --y:${y}; --s:${s}; background-color:${useAccent ? "var(--dot-accent-color)" : color}`}
17
+ />
18
+ );
19
+ }
20
+
21
+ /**
22
+ * DotField defines the styling boundary for a dot-based field.
23
+ * It provides a scoped coordinate system and field-level design tokens
24
+ * (e.g. --field-size, --dot-size) via CSS variables.
25
+ */
26
+ function DotField({
27
+ fieldSize = 200,
28
+ dotSize = 5,
29
+ dotColor = "#f8f8f8",
30
+ dotAccentColor = "#ffff00",
31
+ children,
32
+ bindtouchstart,
33
+ bindtouchmove,
34
+ bindtouchend,
35
+ bindtouchcancel,
36
+ bindlayoutchange,
37
+ }: DotFieldProps) {
38
+ return (
39
+ <view
40
+ /* Styling boundary: inject field-level CSS tokens */
41
+ style={`--field-size:${fieldSize}px; --dot-size:${dotSize}px; --dot-color:${dotColor}; --dot-accent-color:${dotAccentColor} `}
42
+ className="dot-field"
43
+ /* Interaction */
44
+ bindtouchstart={bindtouchstart}
45
+ bindtouchmove={bindtouchmove}
46
+ bindtouchend={bindtouchend}
47
+ bindtouchcancel={bindtouchcancel}
48
+ bindlayoutchange={bindlayoutchange}
49
+ >
50
+ {children}
51
+ </view>
52
+ );
53
+ }
54
+
55
+ export { Dot, DotField };
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "@lynx-js/react";
2
+ import type { LayoutChangeEvent, TouchEvent } from "@lynx-js/types";
3
+
4
+ type DotProps = {
5
+ /** Normalized x position in the field (0..1). */
6
+ x?: number;
7
+
8
+ /** Normalized y position in the field (0..1). */
9
+ y?: number;
10
+
11
+ /** Relative scale factor, resolved against `--dot-size`. Defaults to 1. */
12
+ s?: number;
13
+
14
+ /** Optional explicit color for this dot. */
15
+ color?: string;
16
+
17
+ /** Use the field-level accent color (`--dot-accent-color`). */
18
+ useAccent?: boolean;
19
+ };
20
+
21
+ type DotFieldProps = {
22
+ /** Field size in pixels (defines the coordinate space). */
23
+ fieldSize?: number;
24
+
25
+ /** Base dot size in pixels. */
26
+ dotSize?: number;
27
+
28
+ /** Default dot color (`--dot-color`). */
29
+ dotColor?: string;
30
+
31
+ /** Accent dot color (`--dot-accent-color`). */
32
+ dotAccentColor?: string;
33
+
34
+ /** Extra inline CSS applied at the field boundary. */
35
+ style?: string;
36
+
37
+ /** Field children (dots or other field-aware elements). */
38
+ children?: ReactNode;
39
+
40
+ /** Pointer / touch handlers bound at the field level. */
41
+ bindtouchstart?: (e: TouchEvent) => void;
42
+ bindtouchmove?: (e: TouchEvent) => void;
43
+ bindtouchend?: (e: TouchEvent) => void;
44
+ bindtouchcancel?: (e: TouchEvent) => void;
45
+
46
+ /** Layout measurement handler for field coordinate mapping. */
47
+ bindlayoutchange?: (e: LayoutChangeEvent) => void;
48
+ };
49
+
50
+ export type { DotFieldProps, DotProps };
@@ -0,0 +1,17 @@
1
+ /* =========================
2
+ * Layout
3
+ * ========================= */
4
+
5
+ .design-container {
6
+ width: 100%;
7
+ height: 100%;
8
+ display: flex;
9
+ flex-direction: column;
10
+ justify-content: center;
11
+ align-items: center;
12
+ background-color: #0d0d0d;
13
+ gap: 24px;
14
+ padding: 84px;
15
+ padding-left: 48px;
16
+ padding-right: 48px;
17
+ }
@@ -0,0 +1,100 @@
1
+ import { root } from "@lynx-js/react";
2
+ import { useMemo } from "@lynx-js/react";
3
+
4
+ import { Caption } from "../shared/components/caption/index.jsx";
5
+ import { usePointerFieldPoint } from "../shared/hooks/use-pointer-field-point/index.js";
6
+ import { CELLS } from "./cells.js";
7
+ import { lerpColor4 } from "./color.js";
8
+ import { makeForceField } from "./field-force.js";
9
+ import { Dot, DotField } from "./field.jsx";
10
+ import { clamp01 } from "./math.js";
11
+ import "./index.css";
12
+
13
+ const RADIUS = 0.60;
14
+
15
+ const forceAt = makeForceField({
16
+ radius: RADIUS,
17
+ strength: 0.14,
18
+ falloff: 1.8,
19
+ mode: "repel",
20
+ swirl: 0.6,
21
+ clampMax: 0.14,
22
+ });
23
+
24
+ function App() {
25
+ const {
26
+ p, // Force point in normalized space
27
+ handlePointerDown,
28
+ handlePointerMove,
29
+ handlePointerUp,
30
+ handleElementLayoutChange,
31
+ } = usePointerFieldPoint({ x0: 0.6, y0: 1 });
32
+
33
+ const models = useMemo(() => {
34
+ let minD = Infinity;
35
+ let accentId: string | null = null;
36
+
37
+ // First pass: compute everything and track nearest dot
38
+ const tmp = CELLS.map((cell) => {
39
+ const f = forceAt({ x: cell.cx, y: cell.cy }, p);
40
+
41
+ if (f.d < minD) {
42
+ minD = f.d;
43
+ accentId = cell.id;
44
+ }
45
+
46
+ const x = clamp01(cell.cx + f.dx);
47
+ const y = clamp01(cell.cy + f.dy);
48
+
49
+ const dist01 = clamp01(f.d / RADIUS);
50
+ const g = Math.pow(dist01, 1.25);
51
+
52
+ const s = 0.6 + (1 - g) * 1.8;
53
+ const color = lerpColor4(g, "#EBF4F6", "#7AB2B2", "#088395", "#09637E");
54
+
55
+ return { id: cell.id, x, y, s, color };
56
+ });
57
+
58
+ // Second pass: mark accent
59
+ return tmp.map((m) => ({
60
+ ...m,
61
+ useAccent: m.id === accentId,
62
+ }));
63
+ }, [p.x, p.y]);
64
+
65
+ return (
66
+ <view className="design-container">
67
+ <DotField
68
+ fieldSize={300}
69
+ dotSize={5}
70
+ dotAccentColor="#ff7385"
71
+ bindtouchstart={handlePointerDown}
72
+ bindtouchmove={handlePointerMove}
73
+ bindtouchend={handlePointerUp}
74
+ bindlayoutchange={handleElementLayoutChange}
75
+ >
76
+ {models.map((m) => (
77
+ <Dot
78
+ key={m.id}
79
+ x={m.x}
80
+ y={m.y}
81
+ s={m.s}
82
+ color={m.color}
83
+ useAccent={m.useAccent}
84
+ />
85
+ ))}
86
+ </DotField>
87
+ <Caption
88
+ title="Force Field"
89
+ subtitle="Powered by Inline CSS Variables"
90
+ footnote="Requires Lynx SDK 3.6+"
91
+ />
92
+ </view>
93
+ );
94
+ }
95
+
96
+ root.render(<App />);
97
+
98
+ if (import.meta.webpackHot) {
99
+ import.meta.webpackHot.accept();
100
+ }
@@ -0,0 +1,8 @@
1
+ export function smoothstep01(x: number) {
2
+ const t = clamp01(x);
3
+ return t * t * (3 - 2 * t);
4
+ }
5
+
6
+ export function clamp01(x: number) {
7
+ return x < 0 ? 0 : x > 1 ? 1 : x;
8
+ }
@@ -1,34 +1,22 @@
1
- .container {
2
- width: 100%;
3
- height: 100%;
4
- display: flex;
5
- flex-direction: column;
6
- justify-content: center;
7
- align-items: center;
8
- background-color: black;
9
-
10
- gap: 48px;
11
- }
12
-
13
1
  .stage {
14
- width: 100%;
15
- height: 200px;
16
2
  filter: contrast(100);
17
-
18
3
  /* background color must be explicit, for contrast */
19
4
  background-color: black;
5
+
6
+ width: 100%;
7
+ height: 200px;
20
8
  }
21
9
 
22
10
  .inner-stage {
11
+ /* Lynx: only one filter function per declaration */
12
+ filter: blur(20px);
13
+
23
14
  width: 100%;
24
15
  height: 100%;
25
16
  display: flex;
26
17
  justify-content: center;
27
18
  align-items: center;
28
19
  gap: 4px;
29
-
30
- /* Lynx: only one filter function per declaration */
31
- filter: blur(20px);
32
20
  }
33
21
 
34
22
  .dot {
@@ -72,28 +60,21 @@
72
60
  }
73
61
  }
74
62
 
75
- .caption-container {
63
+ /* =========================
64
+ * Layout
65
+ * ========================= */
66
+
67
+ .design-container {
68
+ min-width: 300px;
69
+ width: 100%;
70
+ height: 100%;
76
71
  display: flex;
77
72
  flex-direction: column;
73
+ justify-content: center;
78
74
  align-items: center;
79
- }
80
-
81
- .title {
82
- font-size: 16px;
83
- font-weight: 600;
84
- line-height: 21px;
85
- color: #f8f8f8;
86
- }
87
-
88
- .subtitle {
89
- line-height: 15px;
90
- font-size: 12px;
91
- font-weight: 400;
92
- color: #b3b3b3;
93
- }
75
+ background-color: black;
76
+ gap: 48px;
94
77
 
95
- .footnote {
96
- line-height: 14px;
97
- font-size: 11px;
98
- color: #4f4f4f;
78
+ padding-left: 0;
79
+ padding-right: 0;
99
80
  }
@@ -1,10 +1,11 @@
1
1
  import { root } from "@lynx-js/react";
2
2
 
3
+ import { Caption } from "../shared/components/caption/index.jsx";
3
4
  import "./index.css";
4
5
 
5
6
  function App() {
6
7
  return (
7
- <view className="container">
8
+ <view className="design-container">
8
9
  <view className="stage">
9
10
  <view className="inner-stage">
10
11
  <view className="dot g1" />
@@ -13,11 +14,11 @@ function App() {
13
14
  <view className="dot g4" />
14
15
  </view>
15
16
  </view>
16
- <view className="caption-container">
17
- <text className="title">Gooey Effect</text>
18
- <text className="subtitle">Powered by CSS filter: contrast + blur</text>
19
- <text className="footnote">Requires Lynx SDK 3.6+</text>
20
- </view>
17
+ <Caption
18
+ title="Gooey Effect"
19
+ subtitle="Powered by CSS filter: contrast + blur"
20
+ footnote="Requires Lynx SDK 3.6+"
21
+ />
21
22
  </view>
22
23
  );
23
24
  }
@@ -0,0 +1,27 @@
1
+ .caption-container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ }
6
+
7
+ .caption-title {
8
+ font-size: 16px;
9
+ font-weight: 600;
10
+ line-height: 21px;
11
+ color: #f8f8f8;
12
+ }
13
+
14
+ .caption-subtitle {
15
+ margin-top: 2px;
16
+ font-size: 12px;
17
+ font-weight: 400;
18
+ line-height: 15px;
19
+ color: #b3b3b3;
20
+ }
21
+
22
+ .caption-footnote {
23
+ margin-top: 4px;
24
+ font-size: 11px;
25
+ line-height: 14px;
26
+ color: #4f4f4f;
27
+ }
@@ -0,0 +1,17 @@
1
+ import "./index.css";
2
+
3
+ type CaptionProps = {
4
+ title: string;
5
+ subtitle?: string;
6
+ footnote?: string;
7
+ };
8
+
9
+ export function Caption({ title, subtitle, footnote }: CaptionProps) {
10
+ return (
11
+ <view className="caption-container">
12
+ <text className="caption-title">{title}</text>
13
+ {subtitle && <text className="caption-subtitle">{subtitle}</text>}
14
+ {footnote && <text className="caption-footnote">{footnote}</text>}
15
+ </view>
16
+ );
17
+ }
@@ -0,0 +1,85 @@
1
+ import { useRef, useState } from "@lynx-js/react";
2
+ import type { LayoutChangeEvent, TouchEvent } from "@lynx-js/types";
3
+ import { usePointerInteraction } from "../use-pointer-interaction/index.js";
4
+
5
+ type FieldPoint = {
6
+ /** Normalized x in [0, 1] (not clamped). */
7
+ x: number;
8
+ /** Normalized y in [0, 1] (not clamped). */
9
+ y: number;
10
+ };
11
+
12
+ type UsePointerFieldPointProps = {
13
+ /** Initial normalized x, defaults to 0.5*/
14
+ x0?: number;
15
+ y0?: number;
16
+ };
17
+
18
+ function usePointerFieldPoint({ x0 = 0.5, y0 = 0.5 }: UsePointerFieldPointProps = {}) {
19
+ const [p, setP] = useState<FieldPoint>({ x: x0, y: y0 });
20
+
21
+ const eleTopRef = useRef<number | null>(null);
22
+ const eleHeightRef = useRef(0);
23
+
24
+ const lastYRatioRef = useRef(y0);
25
+
26
+ const pointer = usePointerInteraction({
27
+ onUpdate(pos) {
28
+ setP({
29
+ x: pos.offsetRatio,
30
+ y: lastYRatioRef.current,
31
+ });
32
+ },
33
+ });
34
+
35
+ const updateYFromEvent = (e: TouchEvent) => {
36
+ const top = eleTopRef.current;
37
+ const height = eleHeightRef.current;
38
+ if (top != null && height > 0) {
39
+ lastYRatioRef.current = (e.detail.y - top) / height;
40
+ }
41
+ };
42
+
43
+ const handlePointerDown2D = (e: TouchEvent) => {
44
+ updateYFromEvent(e);
45
+ pointer.handlePointerDown(e);
46
+ };
47
+
48
+ const handlePointerMove2D = (e: TouchEvent) => {
49
+ updateYFromEvent(e);
50
+ // X handled by existing hook
51
+ pointer.handlePointerMove(e);
52
+ };
53
+
54
+ const handleLayoutChange2D = (e: LayoutChangeEvent) => {
55
+ eleHeightRef.current = e.detail.height;
56
+
57
+ const currentTarget = lynx
58
+ .createSelectorQuery()
59
+ // @ts-expect-error
60
+ .selectUniqueID(e.currentTarget.uid);
61
+
62
+ currentTarget
63
+ ?.invoke({
64
+ method: "boundingClientRect",
65
+ params: { relativeTo: "screen" },
66
+ success: (res: { top: number }) => {
67
+ eleTopRef.current = res.top;
68
+ },
69
+ })
70
+ .exec();
71
+
72
+ pointer.handleElementLayoutChange(e);
73
+ };
74
+
75
+ return {
76
+ p,
77
+ handlePointerDown: handlePointerDown2D,
78
+ handlePointerMove: handlePointerMove2D,
79
+ handlePointerUp: pointer.handlePointerUp,
80
+ handleElementLayoutChange: handleLayoutChange2D,
81
+ };
82
+ }
83
+
84
+ export { usePointerFieldPoint };
85
+ export type { FieldPoint };