curieous commited on
Commit
eaa8d34
·
verified ·
1 Parent(s): 623d0ca

Deploy Epic Errands V1 Space

Browse files
Files changed (3) hide show
  1. app.py +4 -3
  2. frontend/scripts/app.js +93 -17
  3. frontend/styles/app.css +128 -14
app.py CHANGED
@@ -22,8 +22,8 @@ ASSETS_ROOT = FRONTEND_ROOT / "assets"
22
 
23
  def build_server() -> Server:
24
  app = Server(
25
- title="Epic Errands",
26
- description="A fully-custom mobile UI served by a Gradio Server.",
27
  docs_url=None,
28
  redoc_url=None,
29
  )
@@ -39,8 +39,9 @@ def build_server() -> Server:
39
  async def health() -> dict[str, str]:
40
  return {
41
  "status": "ok",
42
- "app": "epic-errands",
43
  "frontend": "fully-custom-ui",
 
44
  }
45
 
46
  @app.get("/api/bootstrap")
 
22
 
23
  def build_server() -> Server:
24
  app = Server(
25
+ title="Epic Errands V1",
26
+ description="A fully-custom V1 mobile UI served by a Gradio Server.",
27
  docs_url=None,
28
  redoc_url=None,
29
  )
 
39
  async def health() -> dict[str, str]:
40
  return {
41
  "status": "ok",
42
+ "app": "epic-errands-v1",
43
  "frontend": "fully-custom-ui",
44
+ "version": "v1",
45
  }
46
 
47
  @app.get("/api/bootstrap")
frontend/scripts/app.js CHANGED
@@ -44,7 +44,8 @@
44
 
45
  const state = {
46
  activeTab: "home",
47
- selectedGoalId: "goal-1",
 
48
  defaultStyleId: "magical",
49
  parentAvatarReady: true,
50
  kidAvatarReady: true,
@@ -91,6 +92,14 @@
91
  .replace(/'/g, "'");
92
  }
93
 
 
 
 
 
 
 
 
 
94
  function currentStyle() {
95
  return styles[state.defaultStyleId];
96
  }
@@ -110,10 +119,10 @@
110
  function goalTitle(goal) {
111
  const style = goalStyle(goal);
112
  const lower = goal.ordinaryGoal.toLowerCase();
113
- if (goal.styleId === "magical") return `${style.goalNoun}: Bright Idea Bridge`;
114
- if (goal.styleId === "comic") return `${style.goalNoun}: Laundry Launch`;
115
  if (lower.includes("read")) return `${style.goalNoun}: Reading Lantern`;
116
- return `${style.goalNoun}: ${goal.ordinaryGoal}`;
117
  }
118
 
119
  function rewardLabel(goal) {
@@ -214,12 +223,24 @@
214
  }
215
 
216
  function renderParentGoals() {
 
217
  return `
218
  <section class="screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
219
  <div class="screen-head">
220
  <h1 class="screen-title" id="parent-title">Parent Goals</h1>
221
- <p class="screen-copy">Approve completed goals and grant the reward. Everything else is handled offline.</p>
222
  </div>
 
 
 
 
 
 
 
 
 
 
 
223
  <div class="goal-list" data-design-id="parent-goal-list">
224
  ${state.goals.map((goal) => {
225
  const style = goalStyle(goal);
@@ -248,45 +269,46 @@
248
  }
249
 
250
  function renderKidGoals() {
251
- const selected = state.goals.find((goal) => goal.id === state.selectedGoalId) || state.goals[0];
252
  return `
253
  <section class="screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
254
  <div class="screen-head">
255
  <h1 class="screen-title" id="kid-title">Kid Goals</h1>
256
- <p class="screen-copy">Tap a goal, finish it, then wait for your grown-up's themed approval.</p>
257
  </div>
258
  <div class="kid-grid" data-design-id="kid-goal-thumbnail-grid">
259
  ${state.goals.map((goal) => {
260
  const style = goalStyle(goal);
261
  return `
262
- <button class="kid-tile" data-action="select-goal" data-goal-id="${goal.id}" aria-pressed="${state.selectedGoalId === goal.id}">
263
  <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} illustration for ${escapeHtml(goal.ordinaryGoal)}">
264
- <span class="tile-body">
265
- <span class="tile-title">${escapeHtml(goalTitle(goal))}</span>
266
- ${stateChip(goal)}
267
- </span>
268
  </button>
269
  `;
270
  }).join("")}
271
  </div>
272
- ${renderGoalCard(selected)}
273
  </section>
274
  `;
275
  }
276
 
277
- function renderGoalCard(goal) {
278
  const style = goalStyle(goal);
279
  const isWaiting = goal.parentRewardState === "waiting_for_approval";
280
  const rewarded = goal.parentRewardState === "approved_reward_given";
281
  const steps = stepsFor(goal);
 
 
 
 
 
282
  return `
283
- <article class="goal-card" data-design-id="kid-goal-card">
 
284
  <div class="goal-card-hero">
285
  <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} goal card image for ${escapeHtml(goal.ordinaryGoal)}">
286
  <span class="hero-badge">${escapeHtml(style.kidTitle)} ${escapeHtml(style.goalNoun)}</span>
287
  </div>
288
  <div class="goal-card-body">
289
- <h2>${escapeHtml(goalTitle(goal))}</h2>
290
  <p class="screen-copy">${escapeHtml(style.copy)}</p>
291
  <ol class="step-list">
292
  ${steps.map((step, index) => `
@@ -321,6 +343,19 @@
321
  `;
322
  }
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  function renderNav() {
325
  const items = [
326
  ["home", "Home", "home"],
@@ -364,6 +399,7 @@
364
  </div>
365
  </header>
366
  <div class="screen-region">${screen}</div>
 
367
  ${renderNav()}
368
  </div>
369
  `;
@@ -388,16 +424,23 @@
388
  if (action === "nav") {
389
  if (target.getAttribute("aria-disabled") === "true") return;
390
  state.activeTab = target.dataset.tab || "home";
 
391
  render();
392
  return;
393
  }
394
 
395
- if (action === "select-goal") {
396
  state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
397
  render();
398
  return;
399
  }
400
 
 
 
 
 
 
 
401
  if (action === "complete-goal") {
402
  const goalId = target.dataset.goalId;
403
  updateGoal(goalId, (goal) => {
@@ -406,6 +449,7 @@
406
  goal.parentRewardState = "waiting_for_approval";
407
  }
408
  });
 
409
  render();
410
  return;
411
  }
@@ -421,5 +465,37 @@
421
  }
422
  });
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  render();
425
  })();
 
44
 
45
  const state = {
46
  activeTab: "home",
47
+ selectedGoalId: null,
48
+ nextGoalNumber: 4,
49
  defaultStyleId: "magical",
50
  parentAvatarReady: true,
51
  kidAvatarReady: true,
 
92
  .replace(/'/g, "&#039;");
93
  }
94
 
95
+ function titleCase(value) {
96
+ return String(value)
97
+ .trim()
98
+ .split(/\s+/)
99
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
100
+ .join(" ");
101
+ }
102
+
103
  function currentStyle() {
104
  return styles[state.defaultStyleId];
105
  }
 
119
  function goalTitle(goal) {
120
  const style = goalStyle(goal);
121
  const lower = goal.ordinaryGoal.toLowerCase();
122
+ if (goal.styleId === "magical" && lower.includes("class project")) return `${style.goalNoun}: Bright Idea Bridge`;
123
+ if (goal.styleId === "comic" && lower.includes("laundry")) return `${style.goalNoun}: Laundry Launch`;
124
  if (lower.includes("read")) return `${style.goalNoun}: Reading Lantern`;
125
+ return `${style.goalNoun}: ${titleCase(goal.ordinaryGoal)}`;
126
  }
127
 
128
  function rewardLabel(goal) {
 
223
  }
224
 
225
  function renderParentGoals() {
226
+ const style = currentStyle();
227
  return `
228
  <section class="screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
229
  <div class="screen-head">
230
  <h1 class="screen-title" id="parent-title">Parent Goals</h1>
231
+ <p class="screen-copy">Create new goals, approve completed ones, and grant the reward.</p>
232
  </div>
233
+ <form class="goal-create panel" data-design-id="parent-goal-create" aria-labelledby="create-goal-title">
234
+ <div>
235
+ <h2 class="section-label" id="create-goal-title">Create goal</h2>
236
+ <p class="goal-create-copy">New goals snapshot the current ${escapeHtml(style.label)} style.</p>
237
+ </div>
238
+ <label class="goal-input-label" for="new-goal-input">Ordinary goal</label>
239
+ <div class="goal-create-row">
240
+ <input id="new-goal-input" name="ordinaryGoal" type="text" autocomplete="off" placeholder="Practice spelling words">
241
+ <button class="create-button" type="submit">${icon("add_task")} Add</button>
242
+ </div>
243
+ </form>
244
  <div class="goal-list" data-design-id="parent-goal-list">
245
  ${state.goals.map((goal) => {
246
  const style = goalStyle(goal);
 
269
  }
270
 
271
  function renderKidGoals() {
 
272
  return `
273
  <section class="screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
274
  <div class="screen-head">
275
  <h1 class="screen-title" id="kid-title">Kid Goals</h1>
276
+ <p class="screen-copy">Tap a thumbnail to open the goal card, finish it, then wait for your grown-up's themed approval.</p>
277
  </div>
278
  <div class="kid-grid" data-design-id="kid-goal-thumbnail-grid">
279
  ${state.goals.map((goal) => {
280
  const style = goalStyle(goal);
281
  return `
282
+ <button class="kid-tile" data-action="open-goal-card" data-goal-id="${goal.id}" aria-label="Open ${escapeHtml(goalTitle(goal))}">
283
  <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} illustration for ${escapeHtml(goal.ordinaryGoal)}">
284
+ <span class="tile-status" aria-hidden="true">${stateChip(goal)}</span>
 
 
 
285
  </button>
286
  `;
287
  }).join("")}
288
  </div>
 
289
  </section>
290
  `;
291
  }
292
 
293
+ function renderGoalCard(goal, options = {}) {
294
  const style = goalStyle(goal);
295
  const isWaiting = goal.parentRewardState === "waiting_for_approval";
296
  const rewarded = goal.parentRewardState === "approved_reward_given";
297
  const steps = stepsFor(goal);
298
+ const modalChrome = options.modal ? `
299
+ <button class="modal-close" data-action="close-goal-card" aria-label="Close goal card">
300
+ ${icon("close")}
301
+ </button>
302
+ ` : "";
303
  return `
304
+ <article class="goal-card" data-design-id="kid-goal-card" ${options.modal ? 'role="dialog" aria-modal="true" aria-labelledby="goal-card-title"' : ""}>
305
+ ${modalChrome}
306
  <div class="goal-card-hero">
307
  <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} goal card image for ${escapeHtml(goal.ordinaryGoal)}">
308
  <span class="hero-badge">${escapeHtml(style.kidTitle)} ${escapeHtml(style.goalNoun)}</span>
309
  </div>
310
  <div class="goal-card-body">
311
+ <h2 id="goal-card-title">${escapeHtml(goalTitle(goal))}</h2>
312
  <p class="screen-copy">${escapeHtml(style.copy)}</p>
313
  <ol class="step-list">
314
  ${steps.map((step, index) => `
 
343
  `;
344
  }
345
 
346
+ function renderGoalModal() {
347
+ const selected = state.goals.find((goal) => goal.id === state.selectedGoalId);
348
+ if (!selected || state.activeTab !== "kid") return "";
349
+ return `
350
+ <div class="goal-modal-backdrop" data-design-id="kid-goal-card-modal">
351
+ <button class="modal-scrim" data-action="close-goal-card" aria-label="Close goal card"></button>
352
+ <div class="goal-modal-shell">
353
+ ${renderGoalCard(selected, { modal: true })}
354
+ </div>
355
+ </div>
356
+ `;
357
+ }
358
+
359
  function renderNav() {
360
  const items = [
361
  ["home", "Home", "home"],
 
399
  </div>
400
  </header>
401
  <div class="screen-region">${screen}</div>
402
+ ${renderGoalModal()}
403
  ${renderNav()}
404
  </div>
405
  `;
 
424
  if (action === "nav") {
425
  if (target.getAttribute("aria-disabled") === "true") return;
426
  state.activeTab = target.dataset.tab || "home";
427
+ if (state.activeTab !== "kid") state.selectedGoalId = null;
428
  render();
429
  return;
430
  }
431
 
432
+ if (action === "open-goal-card") {
433
  state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
434
  render();
435
  return;
436
  }
437
 
438
+ if (action === "close-goal-card") {
439
+ state.selectedGoalId = null;
440
+ render();
441
+ return;
442
+ }
443
+
444
  if (action === "complete-goal") {
445
  const goalId = target.dataset.goalId;
446
  updateGoal(goalId, (goal) => {
 
449
  goal.parentRewardState = "waiting_for_approval";
450
  }
451
  });
452
+ state.selectedGoalId = goalId || state.selectedGoalId;
453
  render();
454
  return;
455
  }
 
465
  }
466
  });
467
 
468
+ root.addEventListener("submit", (event) => {
469
+ const form = event.target.closest("[data-design-id='parent-goal-create']");
470
+ if (!form) return;
471
+ event.preventDefault();
472
+
473
+ const input = form.querySelector("input[name='ordinaryGoal']");
474
+ const ordinaryGoal = input ? input.value.trim() : "";
475
+ if (!ordinaryGoal) {
476
+ if (input) input.focus();
477
+ return;
478
+ }
479
+
480
+ const goal = {
481
+ id: `goal-${state.nextGoalNumber}`,
482
+ ordinaryGoal,
483
+ styleId: state.defaultStyleId,
484
+ kidCompletionState: "not_started",
485
+ parentRewardState: "not_reviewed",
486
+ };
487
+ state.nextGoalNumber += 1;
488
+ state.goals.unshift(goal);
489
+ state.selectedGoalId = null;
490
+ render();
491
+ });
492
+
493
+ window.addEventListener("keydown", (event) => {
494
+ if (event.key === "Escape" && state.selectedGoalId) {
495
+ state.selectedGoalId = null;
496
+ render();
497
+ }
498
+ });
499
+
500
  render();
501
  })();
frontend/styles/app.css CHANGED
@@ -82,6 +82,7 @@ input:focus-visible {
82
  }
83
 
84
  .phone {
 
85
  width: min(100%, 430px);
86
  height: min(920px, calc(100dvh - 36px));
87
  min-height: min(760px, calc(100dvh - 36px));
@@ -216,7 +217,8 @@ input:focus-visible {
216
  .kid-tile,
217
  .goal-card,
218
  .style-option,
219
- .avatar-slot {
 
220
  border: 1px solid var(--line);
221
  border-radius: var(--radius-md);
222
  background: rgba(255, 255, 255, 0.9);
@@ -379,6 +381,49 @@ input:focus-visible {
379
  gap: 10px;
380
  }
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  .goal-row {
383
  display: grid;
384
  grid-template-columns: 1fr auto;
@@ -436,7 +481,8 @@ input:focus-visible {
436
 
437
  .approve-button,
438
  .complete-button,
439
- .audio-button {
 
440
  min-height: 44px;
441
  border: 0;
442
  border-radius: 999px;
@@ -447,7 +493,15 @@ input:focus-visible {
447
  box-shadow: var(--shadow-tight);
448
  }
449
 
 
 
 
 
 
 
 
450
  .approve-button:disabled,
 
451
  .audio-button:disabled {
452
  cursor: not-allowed;
453
  border: 1px solid var(--line);
@@ -463,7 +517,9 @@ input:focus-visible {
463
  }
464
 
465
  .kid-tile {
466
- min-height: 178px;
 
 
467
  overflow: hidden;
468
  text-align: left;
469
  color: var(--ink);
@@ -471,27 +527,65 @@ input:focus-visible {
471
 
472
  .kid-tile img {
473
  width: 100%;
474
- height: 96px;
475
  display: block;
476
  object-fit: cover;
477
  background: var(--theme-soft);
478
  }
479
 
480
- .tile-body {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  display: grid;
482
- gap: 6px;
483
- padding: 10px;
484
  }
485
 
486
- .tile-title {
487
- min-height: 38px;
488
- font-size: 15px;
489
- font-weight: 1000;
490
- line-height: 1.1;
491
  }
492
 
493
- .goal-card {
494
- overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  }
496
 
497
  .goal-card-hero {
@@ -716,6 +810,18 @@ input:focus-visible {
716
  padding: 11px;
717
  }
718
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  .section-label {
720
  margin-bottom: 7px;
721
  font-size: 12px;
@@ -767,6 +873,14 @@ input:focus-visible {
767
  .nav {
768
  min-height: 68px;
769
  }
 
 
 
 
 
 
 
 
770
  }
771
 
772
  @media (max-width: 374px) {
 
82
  }
83
 
84
  .phone {
85
+ position: relative;
86
  width: min(100%, 430px);
87
  height: min(920px, calc(100dvh - 36px));
88
  min-height: min(760px, calc(100dvh - 36px));
 
217
  .kid-tile,
218
  .goal-card,
219
  .style-option,
220
+ .avatar-slot,
221
+ .goal-create {
222
  border: 1px solid var(--line);
223
  border-radius: var(--radius-md);
224
  background: rgba(255, 255, 255, 0.9);
 
381
  gap: 10px;
382
  }
383
 
384
+ .goal-create {
385
+ display: grid;
386
+ gap: 10px;
387
+ padding: 13px;
388
+ }
389
+
390
+ .goal-create-copy {
391
+ margin: 0;
392
+ color: var(--muted);
393
+ font-size: 13px;
394
+ font-weight: 800;
395
+ line-height: 1.3;
396
+ }
397
+
398
+ .goal-input-label {
399
+ color: var(--ink);
400
+ font-size: 12px;
401
+ font-weight: 1000;
402
+ text-transform: uppercase;
403
+ }
404
+
405
+ .goal-create-row {
406
+ display: grid;
407
+ grid-template-columns: minmax(0, 1fr) auto;
408
+ gap: 8px;
409
+ }
410
+
411
+ .goal-create-row input {
412
+ width: 100%;
413
+ min-width: 0;
414
+ min-height: 46px;
415
+ border: 1px solid var(--line);
416
+ border-radius: 999px;
417
+ padding: 0 14px;
418
+ background: #fff;
419
+ color: var(--ink);
420
+ font-weight: 900;
421
+ }
422
+
423
+ .goal-create-row input::placeholder {
424
+ color: #8a98aa;
425
+ }
426
+
427
  .goal-row {
428
  display: grid;
429
  grid-template-columns: 1fr auto;
 
481
 
482
  .approve-button,
483
  .complete-button,
484
+ .audio-button,
485
+ .create-button {
486
  min-height: 44px;
487
  border: 0;
488
  border-radius: 999px;
 
493
  box-shadow: var(--shadow-tight);
494
  }
495
 
496
+ .create-button {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ gap: 6px;
500
+ white-space: nowrap;
501
+ }
502
+
503
  .approve-button:disabled,
504
+ .complete-button:disabled,
505
  .audio-button:disabled {
506
  cursor: not-allowed;
507
  border: 1px solid var(--line);
 
517
  }
518
 
519
  .kid-tile {
520
+ position: relative;
521
+ aspect-ratio: 1 / 1;
522
+ min-height: 132px;
523
  overflow: hidden;
524
  text-align: left;
525
  color: var(--ink);
 
527
 
528
  .kid-tile img {
529
  width: 100%;
530
+ height: 100%;
531
  display: block;
532
  object-fit: cover;
533
  background: var(--theme-soft);
534
  }
535
 
536
+ .tile-status {
537
+ position: absolute;
538
+ left: 8px;
539
+ bottom: 8px;
540
+ max-width: calc(100% - 16px);
541
+ pointer-events: none;
542
+ }
543
+
544
+ .goal-card {
545
+ position: relative;
546
+ overflow: hidden;
547
+ }
548
+
549
+ .goal-modal-backdrop {
550
+ position: absolute;
551
+ z-index: 20;
552
+ inset: 0;
553
  display: grid;
554
+ place-items: end center;
555
+ padding: 18px 14px calc(var(--nav-h) + 14px);
556
  }
557
 
558
+ .modal-scrim {
559
+ position: absolute;
560
+ inset: 0;
561
+ border: 0;
562
+ background: rgba(31, 42, 55, 0.42);
563
  }
564
 
565
+ .goal-modal-shell {
566
+ position: relative;
567
+ z-index: 1;
568
+ width: min(100%, 390px);
569
+ max-height: min(690px, calc(100dvh - var(--nav-h) - 44px));
570
+ overflow: auto;
571
+ border-radius: var(--radius-lg);
572
+ box-shadow: 0 24px 54px rgba(13, 24, 40, 0.28);
573
+ }
574
+
575
+ .modal-close {
576
+ position: absolute;
577
+ z-index: 2;
578
+ top: 10px;
579
+ right: 10px;
580
+ display: grid;
581
+ width: 42px;
582
+ height: 42px;
583
+ place-items: center;
584
+ border: 1px solid rgba(255, 255, 255, 0.72);
585
+ border-radius: 50%;
586
+ background: rgba(255, 255, 255, 0.94);
587
+ color: var(--ink);
588
+ box-shadow: var(--shadow-tight);
589
  }
590
 
591
  .goal-card-hero {
 
810
  padding: 11px;
811
  }
812
 
813
+ .goal-create {
814
+ padding: 11px;
815
+ }
816
+
817
+ .goal-create-row {
818
+ grid-template-columns: 1fr;
819
+ }
820
+
821
+ .create-button {
822
+ justify-content: center;
823
+ }
824
+
825
  .section-label {
826
  margin-bottom: 7px;
827
  font-size: 12px;
 
873
  .nav {
874
  min-height: 68px;
875
  }
876
+
877
+ .goal-modal-backdrop {
878
+ padding: 12px 12px 78px;
879
+ }
880
+
881
+ .goal-modal-shell {
882
+ max-height: calc(100dvh - 96px);
883
+ }
884
  }
885
 
886
  @media (max-width: 374px) {