diff --git a/AGENTS.md b/AGENTS.md index f33dd57..dfd2071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Prefer links or pointers to canonical sources over copied lists. - Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. - Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004 -- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. req: examples/001 +- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction lives in `examples/workout/DESIGN.md`. req: examples/001 - hemx core stays small: effects, typed ids, registries, and wire schema only. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. diff --git a/examples/workout/DESIGN.md b/examples/workout/DESIGN.md new file mode 100644 index 0000000..01129b8 --- /dev/null +++ b/examples/workout/DESIGN.md @@ -0,0 +1,19 @@ +# Workout exemplar design + +Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, and an inspectable private ledger. req: examples/001 + +Preserve: +- First viewport shows one obvious action path before logs or host details. +- Controls stay large enough for sweaty one-handed use; forms remain secondary to tap actions. +- Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003 +- Host capabilities read as a boundary surface, not as app state or a native framework. req: host/002 + +Avoid: +- Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state. +- Equal-weight action grids that make “what now?” ambiguous. +- Styling that requires handwritten selector UI JavaScript. + +Accessibility and responsive boundaries: +- Keep semantic buttons/forms/labels and visible focus rings. +- Minimum tap target is defined by `--tap`; do not reduce it for density. +- Narrow screens are the primary material; wide screens may split the command slate and ledger but must not hide the current action. diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index c583050..6d783f9 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -667,8 +667,8 @@ mod tests { let html = render(&WorkoutState::demo()).to_string(); assert!(html.contains("Now-first Workout Copilot")); assert!(html.contains("Complete set")); - assert!(html.contains("Export via browser/PWA share")); - assert!(html.contains("Request native haptic")); + assert!(html.contains("Share export")); + assert!(html.contains("Haptic")); assert!(!html.contains(&format!("<{}", "script"))); assert!(!html.contains("querySelector")); } diff --git a/examples/workout/src/main.rs b/examples/workout/src/main.rs index b5fe00d..f879de5 100644 --- a/examples/workout/src/main.rs +++ b/examples/workout/src/main.rs @@ -24,6 +24,7 @@ async fn main() { pub fn app(state: AppState) -> Router { Router::new() .route("/", get(page).post(interact)) + .route("/workout.css", get(workout_css)) .route(runtime_js_path(), get(runtime)) .with_state(state) } @@ -41,6 +42,13 @@ async fn interact( request.dispatch_async(workout_app::registry(state)).await } +async fn workout_css() -> impl IntoResponse { + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("../templates/workout.css"), + ) +} + async fn runtime() -> impl IntoResponse { runtime_js() } diff --git a/examples/workout/templates/app_shell.heml b/examples/workout/templates/app_shell.heml index cbcc637..419dd44 100644 --- a/examples/workout/templates/app_shell.heml +++ b/examples/workout/templates/app_shell.heml @@ -4,6 +4,7 @@ hemx workout example + {+= self.body =+} diff --git a/examples/workout/templates/workout.css b/examples/workout/templates/workout.css new file mode 100644 index 0000000..f486aa4 --- /dev/null +++ b/examples/workout/templates/workout.css @@ -0,0 +1,217 @@ +@layer tokens, base, composition, blocks; + +@layer tokens { + :root { + color-scheme: dark; + --surface: #11130f; + --surface-2: #171a14; + --ink: #f7f3e8; + --muted: #aaa38f; + --line: #35392d; + --accent: #d7ff4f; + --accent-ink: #172100; + --warn: #ffba52; + --shadow: 0 18px 70px rgb(0 0 0 / 0.38); + --radius: 24px; + --space: clamp(1rem, 4vw, 1.6rem); + --tap: 3.35rem; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } +} + +@layer base { + * { box-sizing: border-box; } + html { background: var(--surface); } + body { + margin: 0; + min-height: 100svh; + color: var(--ink); + background: + linear-gradient(135deg, rgb(215 255 79 / 0.09), transparent 34rem), + radial-gradient(circle at 50% -14rem, rgb(255 186 82 / 0.18), transparent 28rem), + var(--surface); + } + button, + input { + font: inherit; + } + button { + min-height: var(--tap); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.85rem 1.1rem; + color: var(--ink); + background: var(--surface-2); + font-weight: 800; + letter-spacing: -0.01em; + } + button:focus-visible, + input:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; + } + input { + min-width: 0; + width: 100%; + border: 1px solid var(--line); + border-radius: 16px; + padding: 0.8rem 0.9rem; + color: var(--ink); + background: #0c0e0b; + } + h1, + h2, + h3, + p { + margin-block: 0; + } + pre { + margin: 0; + overflow: auto; + white-space: pre-wrap; + font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} + +@layer composition { + .workout-app { + width: min(100%, 31rem); + margin-inline: auto; + padding: var(--space); + display: grid; + gap: 0.9rem; + } + .command-slate, + .action-rail, + .ledger, + .host-boundary { + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgb(23 26 20 / 0.92); + box-shadow: var(--shadow); + } +} + +@layer blocks { + .command-slate { + min-height: 42svh; + padding: clamp(1.2rem, 8vw, 2.3rem); + display: flex; + flex-direction: column; + justify-content: end; + gap: 1rem; + background: + linear-gradient(180deg, transparent, rgb(0 0 0 / 0.22)), + repeating-linear-gradient(90deg, rgb(215 255 79 / 0.08) 0 1px, transparent 1px 18px), + #181c12; + } + .eyebrow { + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.78rem; + font-weight: 900; + } + .command-slate h1 { + max-width: 12ch; + font-size: clamp(2.3rem, 14vw, 4.4rem); + line-height: 0.92; + letter-spacing: -0.08em; + } + .proofline { + color: var(--muted); + font-weight: 750; + } + .action-rail { + position: sticky; + top: 0.6rem; + z-index: 1; + padding: 0.7rem; + display: grid; + gap: 0.6rem; + } + .primary-action { + color: var(--accent-ink); + background: var(--accent); + border-color: var(--accent); + font-size: 1.08rem; + } + .quiet-action { + color: var(--muted); + } + .micro-form, + .voice-strip { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.55rem; + align-items: end; + } + .micro-form label, + .voice-strip label { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .ledger, + .host-boundary { + padding: 1rem; + display: grid; + gap: 0.85rem; + } + .section-heading { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + } + .ledger h2, + .host-boundary h2 { + font-size: 1rem; + letter-spacing: -0.03em; + } + .ledger h3 { + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.1em; + } + .ledger pre { + border-left: 3px solid var(--accent); + padding: 0.75rem 0.9rem; + background: #0c0e0b; + } + .host-boundary { + border-style: dashed; + } + .host-boundary p { + color: var(--muted); + } + .host-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + } + + @media (min-width: 46rem) { + .workout-app { + width: min(100%, 64rem); + grid-template-columns: minmax(20rem, 1.08fr) minmax(20rem, 0.92fr); + align-items: start; + } + .command-slate, + .action-rail { + grid-column: 1; + } + .ledger, + .host-boundary { + grid-column: 2; + } + .action-rail { + position: static; + } + } +} diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index 2afb8c8..8add88a 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -1,37 +1,41 @@
-
+

Now-first Workout Copilot

-

{+ self.next_action +}

-

{+ self.status +}

+

{+ self.next_action +}

+

{+ self.status +}

-
- -
- - +
+ + + + - -
- - + + + +
-
-

Private event log

+
+
+

Private event log

+ +
{+ self.event_log +}
-

Replay export

+

Export payload

{+ self.export_payload +}
-
-
-

Export host boundary

+
+

Host boundary

{+ self.host_status +}

- - - - +
+ + + + +
diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs index 3816167..8f62c3c 100644 --- a/examples/workout/tests/e2e.rs +++ b/examples/workout/tests/e2e.rs @@ -54,10 +54,19 @@ fn workout_is_e2e_working_over_http() { assert!(home.text().contains("Now-first Workout Copilot")); assert!(home.text().contains("Private event log")); assert!(home.text().contains("Replay export")); + assert!(home + .text() + .contains("