Skip to content

Commit 12a5484

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: add type-safe CSS Grid/Flexbox layout engine and animation subscriptions
Add Tea_Layout module with declarative layout primitives: - CSS units (Px, Rem, Fr, Percent, Vh, Vw, etc.) - Flexbox builders (flexContainerStyle, flexRow, flexColumn) - CSS Grid builders (gridContainerStyle, equalColumns, autoFitGrid) - High-level layouts (quadrantContainerStyle, dashboardContainerStyle) - Style utilities (combineStyles, withPadding, withBackground) Add Animation subscription module to Tea_Sub: - frames: raw requestAnimationFrame binding - framesWithDelta: includes delta time for physics - throttledFrames: rate-limited for heavy workloads - adaptiveFrames: auto-skips when browser is under load - tween: 0.0→1.0 easing with automatic cleanup Add superintendent audit recipes to Justfile: - audit: full layout/animation verification - audit-layout: check Grid/Flexbox primitives - audit-animation: check rAF subscriptions - super-check: verify build environment Include SWOT dashboard example demonstrating usage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f3d9539 commit 12a5484

File tree

8 files changed

+1841
-0
lines changed

8 files changed

+1841
-0
lines changed
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
// SPDX-License-Identifier: MIT AND Palimpsest-0.8
2+
// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell
3+
4+
@@ocaml.doc("
5+
SWOT Matrix Dashboard Example
6+
7+
Demonstrates:
8+
- Tea.Layout for declarative CSS Grid/Flexbox layouts
9+
- Tea.Sub.Animation for smooth transitions without dropped frames
10+
- Quadrant-based layout for SWOT (Strengths, Weaknesses, Opportunities, Threats)
11+
- Throttled animations during heavy recalculations
12+
")
13+
14+
open Tea
15+
16+
// ============================================================================
17+
// Types
18+
// ============================================================================
19+
20+
type swotItem = {
21+
id: string,
22+
text: string,
23+
priority: int,
24+
}
25+
26+
type quadrantData = {
27+
strengths: array<swotItem>,
28+
weaknesses: array<swotItem>,
29+
opportunities: array<swotItem>,
30+
threats: array<swotItem>,
31+
}
32+
33+
type flipState =
34+
| Idle
35+
| Flipping(Layout.quadrant, float)
36+
37+
type model = {
38+
data: quadrantData,
39+
flipState: flipState,
40+
activeQuadrant: option<Layout.quadrant>,
41+
isAnimating: bool,
42+
lastFrameTime: float,
43+
}
44+
45+
type msg =
46+
| StartFlip(Layout.quadrant)
47+
| AnimationFrame(float)
48+
| FlipComplete
49+
| SelectQuadrant(Layout.quadrant)
50+
| ClearSelection
51+
| AddItem(Layout.quadrant, string)
52+
53+
// ============================================================================
54+
// Init
55+
// ============================================================================
56+
57+
let sampleData: quadrantData = {
58+
strengths: [
59+
{id: "s1", text: "Strong CI/CD pipeline", priority: 1},
60+
{id: "s2", text: "Comprehensive test coverage", priority: 2},
61+
{id: "s3", text: "Active maintainer community", priority: 3},
62+
],
63+
weaknesses: [
64+
{id: "w1", text: "Missing SBOM generation", priority: 1},
65+
{id: "w2", text: "Outdated dependencies", priority: 2},
66+
],
67+
opportunities: [
68+
{id: "o1", text: "OpenSSF badge potential", priority: 1},
69+
{id: "o2", text: "Fuzzing integration possible", priority: 2},
70+
{id: "o3", text: "SLSA Level 3 achievable", priority: 3},
71+
],
72+
threats: [
73+
{id: "t1", text: "Supply chain vulnerabilities", priority: 1},
74+
{id: "t2", text: "Unmaintained transitive deps", priority: 2},
75+
],
76+
}
77+
78+
let init = (): (model, Cmd.t<msg>) => {
79+
(
80+
{
81+
data: sampleData,
82+
flipState: Idle,
83+
activeQuadrant: None,
84+
isAnimating: false,
85+
lastFrameTime: 0.0,
86+
},
87+
Cmd.none,
88+
)
89+
}
90+
91+
// ============================================================================
92+
// Update
93+
// ============================================================================
94+
95+
let update = (msg: msg, model: model): (model, Cmd.t<msg>) => {
96+
switch msg {
97+
| StartFlip(quadrant) => (
98+
{...model, flipState: Flipping(quadrant, 0.0), isAnimating: true},
99+
Cmd.none,
100+
)
101+
102+
| AnimationFrame(timestamp) =>
103+
switch model.flipState {
104+
| Flipping(quadrant, progress) =>
105+
let newProgress = progress +. 0.02
106+
if newProgress >= 1.0 {
107+
({...model, flipState: Idle, isAnimating: false, lastFrameTime: timestamp}, Cmd.none)
108+
} else {
109+
({...model, flipState: Flipping(quadrant, newProgress), lastFrameTime: timestamp}, Cmd.none)
110+
}
111+
| Idle => ({...model, lastFrameTime: timestamp}, Cmd.none)
112+
}
113+
114+
| FlipComplete => ({...model, flipState: Idle, isAnimating: false}, Cmd.none)
115+
116+
| SelectQuadrant(quadrant) => ({...model, activeQuadrant: Some(quadrant)}, Cmd.none)
117+
118+
| ClearSelection => ({...model, activeQuadrant: None}, Cmd.none)
119+
120+
| AddItem(_quadrant, _text) => (model, Cmd.none)
121+
}
122+
}
123+
124+
// ============================================================================
125+
// View Components
126+
// ============================================================================
127+
128+
let quadrantTitle = (quadrant: Layout.quadrant): string => {
129+
switch quadrant {
130+
| TopLeft => "Strengths"
131+
| TopRight => "Weaknesses"
132+
| BottomLeft => "Opportunities"
133+
| BottomRight => "Threats"
134+
}
135+
}
136+
137+
let quadrantColor = (quadrant: Layout.quadrant): string => {
138+
switch quadrant {
139+
| TopLeft => "#22c55e"
140+
| TopRight => "#ef4444"
141+
| BottomLeft => "#3b82f6"
142+
| BottomRight => "#f59e0b"
143+
}
144+
}
145+
146+
let itemView = (item: swotItem): React.element => {
147+
let itemStyle = Layout.combineStyles([
148+
Layout.flexRow(~gap=Layout.Px(8), ~justify=Layout.SpaceBetween, ()),
149+
Layout.withPadding(ReactDOM.Style.make(), Layout.Px(8)),
150+
Layout.withBackground(ReactDOM.Style.make(), "rgba(255,255,255,0.1)"),
151+
Layout.withBorderRadius(ReactDOM.Style.make(), Layout.Px(4)),
152+
])
153+
154+
<div key={item.id} style={itemStyle}>
155+
<span> {React.string(item.text)} </span>
156+
<span style={ReactDOM.Style.make(~opacity="0.6", ())}>
157+
{React.string(`P${Belt.Int.toString(item.priority)}`)}
158+
</span>
159+
</div>
160+
}
161+
162+
let quadrantView = (
163+
quadrant: Layout.quadrant,
164+
items: array<swotItem>,
165+
isActive: bool,
166+
flipProgress: option<float>,
167+
dispatch: msg => unit,
168+
): React.element => {
169+
let baseStyle = Layout.quadrantCellStyle(quadrant)
170+
let color = quadrantColor(quadrant)
171+
172+
let transform = switch flipProgress {
173+
| Some(progress) =>
174+
let rotation = progress *. 360.0
175+
`perspective(1000px) rotateY(${Belt.Float.toString(rotation)}deg)`
176+
| None => "none"
177+
}
178+
179+
let cellStyle = Layout.combineStyles([
180+
baseStyle,
181+
Layout.flexColumn(~gap=Layout.Px(12), ()),
182+
Layout.withPadding(ReactDOM.Style.make(), Layout.Px(16)),
183+
Layout.withBackground(ReactDOM.Style.make(), color),
184+
Layout.withBorderRadius(ReactDOM.Style.make(), Layout.Px(8)),
185+
ReactDOM.Style.make(
186+
~transform,
187+
~cursor="pointer",
188+
~border=isActive ? "3px solid white" : "none",
189+
~boxShadow=isActive ? "0 0 20px rgba(255,255,255,0.3)" : "none",
190+
~transition="border 0.2s, box-shadow 0.2s",
191+
(),
192+
),
193+
])
194+
195+
let headerStyle = Layout.spaceBetweenRow()
196+
197+
<div
198+
style={cellStyle}
199+
onClick={_ => dispatch(SelectQuadrant(quadrant))}
200+
onDoubleClick={_ => dispatch(StartFlip(quadrant))}>
201+
<div style={headerStyle}>
202+
<h3 style={ReactDOM.Style.make(~margin="0", ~color="white", ())}>
203+
{React.string(quadrantTitle(quadrant))}
204+
</h3>
205+
<span style={ReactDOM.Style.make(~color="rgba(255,255,255,0.7)", ())}>
206+
{React.string(`(${Belt.Int.toString(Belt.Array.length(items))})`)}
207+
</span>
208+
</div>
209+
<div style={Layout.flexColumn(~gap=Layout.Px(8), ())}>
210+
{items->Belt.Array.map(itemView)->React.array}
211+
</div>
212+
</div>
213+
}
214+
215+
// ============================================================================
216+
// View
217+
// ============================================================================
218+
219+
let view = (model: model, dispatch: msg => unit): React.element => {
220+
let containerStyle = Layout.combineStyles([
221+
Layout.dashboardContainerStyle(Layout.defaultDashboardConfig),
222+
ReactDOM.Style.make(~minHeight="100vh", ~backgroundColor="#1a1a2e", ()),
223+
])
224+
225+
let headerStyle = Layout.combineStyles([
226+
Layout.dashboardAreaStyle(Layout.Header),
227+
Layout.spaceBetweenRow(),
228+
Layout.withPadding(ReactDOM.Style.make(), Layout.Px(16)),
229+
Layout.withBackground(ReactDOM.Style.make(), "#16213e"),
230+
])
231+
232+
let sidebarStyle = Layout.combineStyles([
233+
Layout.dashboardAreaStyle(Layout.Sidebar),
234+
Layout.flexColumn(~gap=Layout.Px(16), ()),
235+
Layout.withPadding(ReactDOM.Style.make(), Layout.Px(16)),
236+
Layout.withBackground(ReactDOM.Style.make(), "#0f3460"),
237+
])
238+
239+
let mainStyle = Layout.combineStyles([
240+
Layout.dashboardAreaStyle(Layout.Main),
241+
Layout.withPadding(ReactDOM.Style.make(), Layout.Px(16)),
242+
])
243+
244+
let quadrantGridStyle = Layout.quadrantContainerStyle({
245+
...Layout.defaultQuadrantConfig,
246+
gap: Some(Layout.Px(16)),
247+
minCellHeight: Some(Layout.Px(250)),
248+
})
249+
250+
let getFlipProgress = (quadrant: Layout.quadrant): option<float> => {
251+
switch model.flipState {
252+
| Flipping(q, progress) if q == quadrant => Some(progress)
253+
| _ => None
254+
}
255+
}
256+
257+
let isActive = (quadrant: Layout.quadrant): bool => {
258+
switch model.activeQuadrant {
259+
| Some(q) => q == quadrant
260+
| None => false
261+
}
262+
}
263+
264+
<div style={containerStyle}>
265+
<header style={headerStyle}>
266+
<h1 style={ReactDOM.Style.make(~color="white", ~margin="0", ())}>
267+
{React.string("SWOT Dashboard")}
268+
</h1>
269+
<div style={Layout.flexRow(~gap=Layout.Px(16), ())}>
270+
<span style={ReactDOM.Style.make(~color="rgba(255,255,255,0.6)", ())}>
271+
{React.string(model.isAnimating ? "Animating..." : "Ready")}
272+
</span>
273+
{switch model.activeQuadrant {
274+
| Some(_) =>
275+
<button
276+
onClick={_ => dispatch(ClearSelection)}
277+
style={ReactDOM.Style.make(
278+
~backgroundColor="#e94560",
279+
~color="white",
280+
~border="none",
281+
~padding="8px 16px",
282+
~borderRadius="4px",
283+
~cursor="pointer",
284+
(),
285+
)}>
286+
{React.string("Clear Selection")}
287+
</button>
288+
| None => React.null
289+
}}
290+
</div>
291+
</header>
292+
<aside style={sidebarStyle}>
293+
<h3 style={ReactDOM.Style.make(~color="white", ~marginTop="0", ())}>
294+
{React.string("Controls")}
295+
</h3>
296+
<p style={ReactDOM.Style.make(~color="rgba(255,255,255,0.7)", ~fontSize="14px", ())}>
297+
{React.string("Click a quadrant to select. Double-click to flip.")}
298+
</p>
299+
<div style={Layout.flexColumn(~gap=Layout.Px(8), ())}>
300+
{[Layout.TopLeft, Layout.TopRight, Layout.BottomLeft, Layout.BottomRight]
301+
->Belt.Array.map(q => {
302+
let btnStyle = ReactDOM.Style.make(
303+
~backgroundColor=quadrantColor(q),
304+
~color="white",
305+
~border="none",
306+
~padding="12px",
307+
~borderRadius="4px",
308+
~cursor="pointer",
309+
~textAlign="left",
310+
(),
311+
)
312+
<button key={quadrantTitle(q)} style={btnStyle} onClick={_ => dispatch(StartFlip(q))}>
313+
{React.string(`Flip ${quadrantTitle(q)}`)}
314+
</button>
315+
})
316+
->React.array}
317+
</div>
318+
</aside>
319+
<main style={mainStyle}>
320+
<div style={quadrantGridStyle}>
321+
{quadrantView(
322+
Layout.TopLeft,
323+
model.data.strengths,
324+
isActive(Layout.TopLeft),
325+
getFlipProgress(Layout.TopLeft),
326+
dispatch,
327+
)}
328+
{quadrantView(
329+
Layout.TopRight,
330+
model.data.weaknesses,
331+
isActive(Layout.TopRight),
332+
getFlipProgress(Layout.TopRight),
333+
dispatch,
334+
)}
335+
{quadrantView(
336+
Layout.BottomLeft,
337+
model.data.opportunities,
338+
isActive(Layout.BottomLeft),
339+
getFlipProgress(Layout.BottomLeft),
340+
dispatch,
341+
)}
342+
{quadrantView(
343+
Layout.BottomRight,
344+
model.data.threats,
345+
isActive(Layout.BottomRight),
346+
getFlipProgress(Layout.BottomRight),
347+
dispatch,
348+
)}
349+
</div>
350+
</main>
351+
</div>
352+
}
353+
354+
// ============================================================================
355+
// Subscriptions
356+
// ============================================================================
357+
358+
let subscriptions = (model: model): Sub.t<msg> => {
359+
if model.isAnimating {
360+
Sub.Animation.throttledFrames(16.67, timestamp => AnimationFrame(timestamp))
361+
} else {
362+
Sub.none
363+
}
364+
}
365+
366+
// ============================================================================
367+
// App
368+
// ============================================================================
369+
370+
module App = MakeWithDispatch({
371+
type model = model
372+
type msg = msg
373+
let app = {
374+
init: () => init(),
375+
update,
376+
view,
377+
subscriptions,
378+
}
379+
})

0 commit comments

Comments
 (0)