@lovek4d/kickflip 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.
Files changed (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,164 @@
1
+ /* The hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), whose root carries .hub-view, and the hub's
2
+ down page (hub/hub.mjs), whose <html> does; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
3
+ .hub-view, since style.css uses the same short class names. The app shell links it after style.css, so a rule here
4
+ wins a tie, and the header is style.css's alone, as on every view; each style.css rule that would reach these rows
5
+ is set back here. */
6
+ .hub-view { --mono: ui-monospace,"SF Mono",Menlo,monospace; --sans: -apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",system-ui,sans-serif; }
7
+ @media (prefers-color-scheme: dark) { .hub-view { color-scheme: dark; } }
8
+ .hub-view, .hub-view * { box-sizing: border-box; }
9
+ .hub-view [hidden] { display:none !important; }
10
+ .hub-view { -webkit-text-size-adjust:100%; }
11
+ /* clip, not hidden: hidden makes body a scroll container and breaks the sticky header. iOS zooms on a focused field under 16px. */
12
+ .hub-view body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 var(--sans); overflow-x:clip; }
13
+ .hub-view main { max-width: 40rem; margin: 0 auto; padding: max(1.25rem, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 2.5rem max(16px, env(safe-area-inset-left)); }
14
+ .hub-view main.has-top { padding-top: 12px; }
15
+ .hub-view a, .hub-view a:hover { color:var(--accent); text-decoration:none; }
16
+ .hub-view main > .back { display:inline-block; font-size:1rem; margin-bottom:.2rem; }
17
+ .hub-view main h1 { font-size:1.6rem; line-height:1.2; margin:.5rem 0 .3rem; overflow-wrap:anywhere; }
18
+ .hub-view .lede { color:var(--muted); margin:0; }
19
+ .hub-view .label { margin:16px 2px 6px; font-size:12px; font-weight:400; letter-spacing:.05em; text-transform:uppercase; color:var(--muted); }
20
+ .hub-view .group { background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden; }
21
+ .hub-view .row { display:flex; align-items:center; justify-content:normal; gap:10px; margin:0; padding:10px 12px; color:var(--text); min-height:3.25rem; }
22
+ .hub-view a.row:active { background:var(--chip); }
23
+ @media (hover: hover) { .hub-view a.row:hover { background:color-mix(in srgb, var(--chip) 60%, transparent); } }
24
+ .hub-view .group > .row:not([hidden]) ~ .row { border-top:1px solid var(--line); }
25
+ .hub-view .body { flex:1; min-width:0; display:flex; align-items:center; gap:10px; }
26
+ .hub-view .text { flex:1; min-width:0; }
27
+ .hub-view .title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
28
+ .hub-view .sub { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; overflow-wrap:normal; }
29
+ .hub-view .sub.wrap { white-space:normal; }
30
+ .hub-view .st { font-weight:600; } .hub-view .st.down { color:var(--color-danger); } .hub-view .st.wait { color:var(--color-notice); }
31
+ @keyframes pulse { 50% { opacity:.35; } }
32
+ .hub-view main .icon { flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; color:var(--c); background:color-mix(in srgb, var(--c) 18%, transparent); font-weight:700; font-size:16px; line-height:1; }
33
+ .hub-view main .icon.sm { width:29px; height:29px; border-radius:7px; }
34
+ .hub-view main .icon.solid { color:#fff; background:var(--c); }
35
+ @media (prefers-color-scheme: dark) { .hub-view main .icon:not(.solid) { color:color-mix(in srgb, var(--c) 70%, #fff); } }
36
+ .hub-view main .icon svg { display:block; }
37
+ .hub-view .badge { flex:none; min-width:22px; height:22px; padding:0 7px; border-radius:11px; background:var(--color-danger); color:#fff; font-size:12px; font-weight:600; line-height:22px; text-align:center; font-variant-numeric:tabular-nums; }
38
+ .hub-view .chev { flex:none; color:var(--muted); font-size:1.3rem; line-height:1; }
39
+ .hub-view .val { flex:none; color:var(--muted); font-size:13px; }
40
+ .hub-view .btn { flex:none; font:inherit; font-size:13px; color:#fff; background:var(--accent); border:1px solid var(--accent); border-radius:7px; padding:6px 10px; cursor:pointer; }
41
+ .hub-view .btn.quiet { color:var(--text); background:var(--panel); border-color:var(--line); }
42
+ .hub-view .btn:disabled { opacity:.5; cursor:default; }
43
+ .hub-view .btn.danger { color:var(--color-danger); }
44
+ .hub-view .block { padding:10px 12px; }
45
+ .hub-view .group > :is(.row, .block) ~ .block, .hub-view .group > .block ~ .row { border-top:1px solid var(--line); }
46
+ .hub-view .chips form { display:contents; }
47
+ .hub-view .chips form.field { display:inline-flex; gap:6px; }
48
+ .hub-view .chips .field input { min-height:0; padding:.2rem .5rem; width:7.5rem; }
49
+ .hub-view .split { display:flex; gap:8px; justify-content:flex-end; }
50
+ .hub-view .day { position:relative; padding:18px 0 20px; }
51
+ .hub-view .day .bar { position:relative; height:40px; border-radius:8px; overflow:hidden; background:var(--chip); }
52
+ .hub-view .day .seg { position:absolute; top:0; bottom:0; display:grid; place-items:center; overflow:hidden; border-right:2px solid var(--panel);
53
+ background:color-mix(in srgb, var(--accent) var(--k), var(--chip)); }
54
+ .hub-view .day .seg.held { background-image:repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--text) 22%, transparent) 5px 8px); }
55
+ .hub-view .day .seg.sel { box-shadow:inset 0 0 0 3px var(--text); z-index:2; }
56
+ .hub-view .day .seg span { font-size:11px; font-weight:600; line-height:1.5; color:var(--text); background:color-mix(in srgb, var(--panel) 85%, transparent); border-radius:4px; padding:0 4px; }
57
+ .hub-view .day .pin { position:absolute; top:2px; transform:translateX(-50%); color:var(--color-notice); font-size:11px; line-height:1; }
58
+ .hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--color-danger); z-index:3; pointer-events:none; }
59
+ .hub-view .day .ticks { position:absolute; left:0; right:0; bottom:0; height:16px; font-size:10px; color:var(--muted); }
60
+ .hub-view .day .ticks span { position:absolute; transform:translateX(-50%); }
61
+ .hub-view .day .ticks span:first-child { transform:none; } .hub-view .day .ticks span:last-child { transform:translateX(-100%); }
62
+ .hub-view .legend { display:flex; gap:4px 12px; flex-wrap:wrap; font-size:11px; color:var(--muted); }
63
+ .hub-view .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:-1px; margin-right:4px; }
64
+ .hub-view #day, .hub-view #window, .hub-view #quiet, .hub-view #wakes { scroll-margin-top:64px; }
65
+ .hub-view .field input { flex:none; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:.3rem .6rem; min-height:2.2rem; width:8.75rem; min-width:0; text-align:right; }
66
+ .hub-view .field input[type=number] { width:5rem; }
67
+ .hub-view .field input.toggle { width:1.4rem; height:1.4rem; min-height:0; padding:0; accent-color:var(--accent); }
68
+ .hub-view .field input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
69
+ .hub-view .note { margin:6px 2px 0; color:var(--muted); font-size:12px; }
70
+ .hub-view .segments { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:3px; padding:3px; border:1px solid var(--line); border-radius:9px; background:var(--chip); }
71
+ .hub-view .segments button { min-width:0; min-height:44px; padding:6px; border:1px solid transparent; border-radius:6px; background:transparent; color:var(--muted); font:inherit; cursor:pointer; }
72
+ .hub-view .segments button[aria-pressed="true"] { border-color:var(--accent); background:var(--accent-soft); color:var(--accent); font-weight:600; }
73
+ .hub-view .segments button:focus-visible { outline:2px solid var(--accent); outline-offset:-3px; }
74
+ .hub-view .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
75
+ .hub-view .actions { margin-top:1.2rem; display:flex; justify-content:flex-end; }
76
+ .hub-view .flash { animation:none; margin-top:1rem; padding:10px 12px; border-radius:10px; background:var(--panel); border:1px solid var(--line); color:var(--color-success); }
77
+ .hub-view .flash.error { color:var(--color-danger); }
78
+ .hub-view .hold-banner { display:flex; align-items:center; gap:10px; margin:4px 0 14px; padding:8px 8px 8px 12px; min-height:46px; border-radius:10px; border:1px solid color-mix(in srgb, var(--color-notice) 45%, transparent); background:color-mix(in srgb, var(--color-notice) 11%, var(--panel)); font-size:13.5px; line-height:1.35; }
79
+ .hub-view .hold-banner > svg { flex:none; color:var(--color-notice); }
80
+ .hub-view .hold-banner > span { flex:1; min-width:0; }
81
+ .hub-view .hold-banner form { flex:none; margin:0; }
82
+ .hub-view .hold-banner .btn { min-height:32px; }
83
+ .hub-view .snooze-head { display:flex; align-items:center; gap:10px; }
84
+ .hub-view .snooze-chips { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:6px; margin-top:10px; }
85
+ .hub-view .snooze-choices { display:contents; }
86
+ .hub-view .snooze-chips .btn { display:block; min-height:40px; padding:6px 4px; text-align:center; white-space:nowrap; }
87
+ .hub-view .snooze-custom { position:relative; margin:0; }
88
+ .hub-view .snooze-custom .btn { display:grid; place-items:center; height:100%; }
89
+ .hub-view .snooze-custom input[type=datetime-local] { position:absolute; inset:0; width:100%; height:100%; min-height:0; padding:0; margin:0; border:0; background:transparent; color:transparent; opacity:.011; font-size:16px; cursor:pointer; }
90
+ .hub-view .snooze-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
91
+ .hub-view .snooze-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
92
+ .hub-view .snooze-on .title { font-weight:600; white-space:normal; }
93
+ .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
94
+ .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
95
+ .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
96
+ .hub-view .st.ok { color:var(--color-success); }
97
+ .hub-view .links { display:block; margin-top:2px; }
98
+ .hub-view .links > span { display:block; color:var(--muted); font-size:12px; overflow-wrap:anywhere; }
99
+ .hub-view .links b { font-weight:600; }
100
+ .hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
101
+ .hub-view .remembered p { margin:0; }
102
+ .hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
103
+ .hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
104
+
105
+ @media (max-width: 899px) {
106
+ .hub-view main.has-top { padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: calc(40px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
107
+ }
108
+
109
+ .hub-view .hub-settings { margin-top: 24px; }
110
+ .hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
111
+ .hub-view .hub-setting .text { flex: 1; min-width: 0; }
112
+ .hub-view .hub-setting .title { font-weight: 600; }
113
+ .hub-view .hub-setting .sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
114
+ .hub-view .hub-modal .settings-body { padding-top: 2px; }
115
+ .hub-view .hub-modal .setting-content > .label:first-child { margin-top: 14px; }
116
+ .hub-view .hub-modal .setting-content > .group:first-child { margin-top: 16px; }
117
+ .hub-view .hub-modal .rules-box { margin-top: 14px; }
118
+
119
+ .hub-view .u-tone-ok { --u: var(--accent); }
120
+ .hub-view .u-tone-high { --u: var(--color-notice); }
121
+ .hub-view .u-tone-full { --u: var(--color-danger); }
122
+ .hub-view .u-tone-stale, .hub-view .u-tone-none { --u: var(--muted); }
123
+ .hub-view .u-bar { display: block; height: 4px; border-radius: 2px; background: var(--chip); overflow: hidden; }
124
+ .hub-view .u-bar > i { display: block; height: 100%; border-radius: inherit; background: var(--u); }
125
+ .hub-view .u-tone-stale .u-bar, .hub-view .u-tone-none .u-bar { background: repeating-linear-gradient(135deg, var(--chip) 0 4px, color-mix(in srgb, var(--muted) 22%, var(--chip)) 4px 6px); }
126
+ .hub-view .u-num { color: var(--u); font-weight: 600; font-variant-numeric: tabular-nums; }
127
+ .hub-view .u-tone-ok .u-num { color: inherit; font-weight: 400; }
128
+
129
+ .hub-view .u-block .u-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
130
+ .hub-view .u-block .u-head .title { font-weight: 600; }
131
+ .hub-view .u-win { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 5px; font-size: 13px; }
132
+ .hub-view .u-win > :last-child { color: var(--muted); text-align: right; }
133
+ .hub-view .u-block .u-bar { height: 6px; border-radius: 3px; }
134
+ .hub-view .u-asof { color: var(--muted); font-size: 12px; }
135
+ .hub-view .u-share { margin-top: 8px; }
136
+ .hub-view .u-codex-bars { margin-top: 14px; padding-top: 4px; border-top: 1px solid var(--line); }
137
+ .hub-view .segments.is-disabled { opacity: .55; }
138
+ .hub-view .segments.is-disabled button { cursor: not-allowed; }
139
+ .hub-view .segments.is-disabled button[aria-pressed="true"] { border-color: var(--muted); background: var(--panel); color: var(--text); }
140
+ .hub-view .u-share-desc { margin: 8px 2px 0; color: var(--muted); font-size: 13px; }
141
+ .hub-view .u-share-note { margin: 4px 2px 0; color: var(--muted); font-size: 12px; }
142
+ .hub-view .u-share-fix { margin: 6px 2px 0; }
143
+ .hub-view .u-share-fix code { display: inline-block; padding: 3px 7px; border-radius: 6px; background: var(--chip); color: var(--text); font-size: 12.5px; user-select: all; }
144
+ .hub-view .lk-form { display:flex; gap:8px; align-items:center; }
145
+ .hub-view .lk-form input { flex:1; min-width:0; font:16px var(--mono); color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:.3rem .6rem; min-height:2.75rem; }
146
+ .hub-view .lk-form input::placeholder { font-family:var(--sans); }
147
+ .hub-view .lk-form input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
148
+
149
+ .hub-view .ub-banner > button { flex: none; min-height: 32px; }
150
+ .hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
151
+ .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
152
+
153
+ .hub-view .u-buffer { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
154
+ .hub-view .ub-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
155
+ .hub-view .ub-label { min-width: 0; }
156
+ .hub-view .ub-label .title { display: block; font-size: 14px; }
157
+ .hub-view .ub-sub { display: block; color: var(--muted); font-size: 12px; }
158
+ .hub-view .segments.ub-step { flex: none; grid-template-columns: 44px 62px 44px; }
159
+ .hub-view .ub-step button { padding: 0; font-size: 20px; line-height: 1; color: var(--text); }
160
+ .hub-view .ub-step button:disabled { color: var(--muted); opacity: .45; cursor: default; }
161
+ .hub-view .ub-step output { display: grid; place-items: center; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent-soft); color: var(--accent); font-weight: 600; font-variant-numeric: tabular-nums; }
162
+ .hub-view .ub-step output.off { border-color: var(--line); background: var(--panel); color: var(--muted); }
163
+ .hub-view .ub-marked .u-bar { position: relative; }
164
+ .hub-view .ub-marked .u-bar > .ub-mark { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; border-radius: 0; background: var(--text); opacity: .5; }
Binary file
@@ -0,0 +1,64 @@
1
+ // Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
2
+ // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/0060-background-sessions-prompts-come-to-the-phone.md).
3
+ // Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
4
+ // since it covers every later call like it until the session ends. Several questions, or one taking several picks,
5
+ // send with Send once each has a pick.
6
+ globalThis.PromptAnswers = (function () {
7
+ // B: the view's recorders. list() → the prompts the view shows; paint() repaints their lines; notify(text) shows a
8
+ // short message; after() runs once an answer has gone. → {state(p) for promptItem, shows(p)}
9
+ function wire(B, { list, paint, notify, after = () => {} }) {
10
+ const picks = new Map();
11
+ const sending = new Set();
12
+ // Answered here, hidden until the view's next load drops them.
13
+ const answered = new Set();
14
+ const picksOf = (id) => { if (!picks.has(id)) picks.set(id, {}); return picks.get(id); };
15
+
16
+ async function send(p, body) {
17
+ sending.add(p.id);
18
+ paint();
19
+ try {
20
+ const r = await fetch(`/_hub/prompts/${encodeURIComponent(p.id)}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
21
+ if (r.ok || r.status === 404 || r.status === 409) { answered.add(p.id); picks.delete(p.id); }
22
+ if (r.status === 404) notify('That prompt has expired.');
23
+ else if (r.status === 409) notify('That prompt was already answered.');
24
+ else if (!r.ok) notify((await r.json().catch(() => null))?.error || 'Could not send.');
25
+ } catch { notify('Could not send. Is the host awake?'); }
26
+ sending.delete(p.id);
27
+ paint();
28
+ after();
29
+ }
30
+
31
+ B.on(B.root, 'click', async (ev) => {
32
+ const li = ev.target.closest?.('li[data-prompt]');
33
+ const p = li && list().find((x) => x.id === li.dataset.prompt);
34
+ if (!p || sending.has(p.id) || answered.has(p.id)) return;
35
+ const opt = ev.target.closest('button[data-prompt-opt]');
36
+ if (opt) {
37
+ const i = Number(opt.dataset.promptQ);
38
+ const q = p.questions[i];
39
+ const mine = picksOf(p.id);
40
+ const got = mine[i] || [];
41
+ const label = opt.dataset.promptOpt;
42
+ mine[i] = q.multiSelect ? (got.includes(label) ? got.filter((l) => l !== label) : [...got, label]) : [label];
43
+ if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine));
44
+ else paint();
45
+ return;
46
+ }
47
+ if (ev.target.closest('button[data-prompt-send]')) {
48
+ const body = BoardUI.promptAnswer(p, picksOf(p.id));
49
+ if (body) send(p, body);
50
+ return;
51
+ }
52
+ const choice = ev.target.closest('button[data-prompt-choice]')?.dataset.promptChoice;
53
+ if (!choice) return;
54
+ if (choice === 'always' && !(await BoardDialogs.confirm(`Always allow ${p.rule} for the rest of this session?`))) return;
55
+ send(p, { choice });
56
+ });
57
+
58
+ return {
59
+ state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.has(p.id) }),
60
+ shows: (p) => !answered.has(p.id),
61
+ };
62
+ }
63
+ return { wire };
64
+ })();
@@ -0,0 +1,193 @@
1
+ // What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
2
+ // with board.mjs prototype add (docs/adr/0089-the-board-serves-every-prototype-from-one-template.md). A take file,
3
+ // public/prototypes/<topic>/<KEY>.js, is a module:
4
+ // export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
5
+ // render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
6
+ // and paint, so nothing in it, position: fixed included, can cover the header, the take pill or the app shell.
7
+ // kit.sidebar(data?, {here, switching, device, head, height}) ui-sidebar.js sidebar() as an open drawer card in the
8
+ // page's flow, rail width from 900px; head shows its Kickflip header
9
+ // kit.hub(data?) the Hub page (view-hub.js) in flow: {usage, usageCard (HTML in its place),
10
+ // afterUsage (HTML), hub, sites, polling, settings: [[id, title, sub]], header}
11
+ // kit.cards(items?, {lanes}) board cards (ui-cards.js workLines) in their lanes, or one list with lanes false
12
+ // kit.sheet(html, {title}) a sheet drawn in flow over a dimmed strip, not a real dialog
13
+ // kit.toast(text) the page's toast
14
+ // kit.on(target, type, fn, opts) a listener removed on the next switch
15
+ // kit.fake made-up data: at(ms), sites(), usage({claude, codex}), sidebar(), hub(), items()
16
+ // kit.esc HTML escaping
17
+ // BoardUI and HubUI, the real renderers, are globals too. A link in the stage never navigates and a form never
18
+ // submits: each toasts unless the take's own handler shows a toast. Listeners a take adds to document or window,
19
+ // and its timers, end on the next switch. Interactions must work on a tap: a phone has no hover.
20
+ (function () {
21
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
22
+ const wait = globalThis.setTimeout.bind(globalThis);
23
+ const unwait = globalThis.clearTimeout.bind(globalThis);
24
+
25
+ const at = (ms) => new Date(Date.now() + ms).toISOString();
26
+ const svg = (paths) => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
27
+ const siteIcon = (color, paths) => `<span class="icon" style="--c:${color}" aria-hidden="true">${svg(paths)}</span>`;
28
+ const lanes = (...l) => `<span class="lcs">${l.map(([k, t]) => `<span class="lc lc-${k}">${t}</span>`).join('')}</span>`;
29
+ const reading = (five, week, agoMs, resetH, weekD) => ({ at: at(-agoMs), five_hour: { used_percentage: five, resets_at: at(resetH * 3600_000), state: 'live' }, seven_day: { used_percentage: week, resets_at: at(weekD * 86400_000), state: 'live' } });
30
+ const fake = {
31
+ at,
32
+ sites: () => [
33
+ { route: 'backend-audit', title: 'Backend Audit', board: true, count: 0, created: '2026-10-22T21:47:40Z', icon: siteIcon('#8b5cf6', '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/><path d="m8.5 11 1.8 1.8 3.4-3.6"/>'), sub: lanes(['review', '3 review'], ['todo', '15 todo']) },
34
+ { route: 'hub-orchestrator', title: 'Hub Orchestrator', board: true, count: 1, created: '2026-10-29T21:49:30Z', icon: siteIcon('#f97316', '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01"/>'), sub: lanes(['progress', '3 doing'], ['todo', '9 todo']) },
35
+ { route: 'ios-orchestrator', title: 'iOS Orchestrator', board: true, count: 0, created: '2026-10-22T19:19:11Z', icon: siteIcon('#3b82f6', '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>'), sub: lanes(['review', '4 review'], ['todo', '6 todo']) },
36
+ ],
37
+ // Percent used in each product's 5-hour window, or [5-hour, week]; null leaves that product's reading out.
38
+ usage: ({ claude = 34, codex = 12 } = {}) => ({
39
+ ...(claude === null ? {} : { claude: reading(...[claude, 61].flat().slice(0, 2), 2 * 60_000, 3.4, 2.6) }),
40
+ ...(codex === null ? {} : { codex: reading(...[codex, 40].flat().slice(0, 2), 6 * 60_000, 1.2, 4.1) }),
41
+ }),
42
+ sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
43
+ hub: () => ({
44
+ usage: fake.usage(),
45
+ hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' },
46
+ sites: fake.sites(),
47
+ polling: { title: 'Checks every 15 min until 6:00pm', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
48
+ settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['polling', 'Polling', '3 windows, 52 checks a day · held overnight'],
49
+ ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
50
+ }),
51
+ items: () => [
52
+ { lane: 'review', title: 'Audit log keeps the actor on bulk updates', ref: 'INT-41', pr: { url: 'https://github.com/o/app/pull/312', number: 312, state: 'OPEN', isDraft: false, checks: { state: 'pass' } }, reviewers: ['Quinn'], review: { Quinn: { state: 'sent', since: at(-200 * 60_000) } } },
53
+ { lane: 'progress', title: 'Rate limits per API key', ref: 'INT-43', builder: { progress: 'Writing the limiter test', at: at(-4 * 60_000) } },
54
+ { lane: 'todo', title: 'Drop the unused lead_sources index', ref: 'INT-44', next: 'Ready to start' },
55
+ ],
56
+ };
57
+
58
+ function sidebar(data = fake.sidebar(), { here = '', switching = false, device = null, head = false, height = null } = {}) {
59
+ const style = height ? ` style="height:${Number(height)}px"` : '';
60
+ return `<aside class="sidebar kit-sidebar${head ? ' kit-head' : ''}${switching ? ' ks-on' : ''}" aria-label="Boards"${style}>${BoardUI.sidebar(data, { here, switching, device })}</aside>`;
61
+ }
62
+
63
+ function hub(data = {}) {
64
+ const d = { ...fake.hub(), header: true, ...data };
65
+ const { row, tile, nowRow, CHEVRON } = HubUI;
66
+ const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">Up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
67
+ const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
68
+ const usage = d.usageCard ?? HubUI.usageCard(d.usage);
69
+ const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
70
+ return `<div class="hub-view view-hub kit-hub">${header}<main class="has-top"><div class="hub-status">${usage}${d.afterUsage || ''}<h2 class="label">Health</h2>
71
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
72
+ <h2 class="label">Boards</h2>
73
+ <div class="group">${d.sites.map(site).join('')}</div>
74
+ <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
75
+ <h2 class="label">Polling status</h2>
76
+ <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
77
+ <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
78
+ <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span></footer></div></main></div>`;
79
+ }
80
+
81
+ const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
82
+ function card(it, i) {
83
+ const full = { key: `w/C${i}`, id: `C${i}`, kind: 'work', internal: !it.linear && !it.ticket, ref: `C${i}`, lane: 'todo', deps: [], blockedBy: [], held: false, bumpable: false, isNew: false,
84
+ reviewers: [], review: {}, pr: null, builder: null, next: null, ...it };
85
+ return `<details class="item" data-key="${esc(full.key)}"><summary class="work"><span class="status ${esc(full.lane)}"></span><div class="title-wrap">${BoardUI.workLines(full)}</div></summary></details>`;
86
+ }
87
+ function cards(items = fake.items(), { lanes: grouped = true } = {}) {
88
+ if (!grouped) return `<div class="view-board kit-cards"><div class="lane"><div class="list">${items.map(card).join('')}</div></div></div>`;
89
+ return `<div class="view-board kit-cards">${LANES.map(([id, name]) => {
90
+ const list = items.map((it, i) => [it, i]).filter(([it]) => (it.lane || 'todo') === id);
91
+ return list.length ? `<details class="lane" data-lane="${id}" open><summary><span class="lane-name">${name}</span><span class="count">${list.length}</span></summary><div class="list">${list.map(([it, i]) => card(it, i)).join('')}</div></details>` : '';
92
+ }).join('')}</div>`;
93
+ }
94
+
95
+ const sheet = (html, { title = '' } = {}) => `<div class="kit-sheet-strip"><section class="kit-sheet" role="group" aria-label="${esc(title)}"><div class="sheet-head"><h2>${esc(title)}</h2><button type="button" data-kit-inert aria-label="Close sheet">✕</button></div><div class="kit-sheet-body">${html}</div></section></div>`;
96
+
97
+ let toasted = 0;
98
+ function toast(text) {
99
+ const el = document.getElementById('protoToast');
100
+ if (!el) return;
101
+ toasted++;
102
+ el.textContent = text;
103
+ el.hidden = false;
104
+ unwait(toast.t);
105
+ toast.t = wait(() => { el.hidden = true; }, 2600);
106
+ }
107
+
108
+ // What a take leaves behind: listeners on document and window, and timers, recorded while it shows. Only functions
109
+ // made in this page count, since the app shell adds its own listeners to a framed page's document (view-frame.js).
110
+ const ends = [];
111
+ let live = false;
112
+ const nativeAdd = EventTarget.prototype.addEventListener;
113
+ const nativeRemove = EventTarget.prototype.removeEventListener;
114
+ for (const target of [window, document]) {
115
+ Object.defineProperty(target, 'addEventListener', { configurable: true, value(type, fn, opts) {
116
+ const self = this ?? window;
117
+ nativeAdd.call(self, type, fn, opts);
118
+ if (live && fn instanceof Function) ends.push(() => nativeRemove.call(self, type, fn, opts));
119
+ } });
120
+ }
121
+ for (const [set, clear] of [['setTimeout', 'clearTimeout'], ['setInterval', 'clearInterval']]) {
122
+ const native = window[set].bind(window);
123
+ const nativeClear = window[clear].bind(window);
124
+ window[set] = (...args) => { const id = native(...args); if (live) ends.push(() => nativeClear(id)); return id; };
125
+ }
126
+ const on = (target, type, fn, opts) => { nativeAdd.call(target, type, fn, opts); ends.push(() => nativeRemove.call(target, type, fn, opts)); };
127
+
128
+ const kit = { sidebar, hub, cards, sheet, toast, on, fake, esc };
129
+
130
+ function start(data) {
131
+ const keys = data.takes.map((t) => t.key);
132
+ const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
133
+ let cleanup = null;
134
+ let shown = 0;
135
+
136
+ // Capture runs before the app shell's own click handler on a framed page, which would open a board link.
137
+ const inStage = (e, sel) => e.target.closest?.(`#stage ${sel}`);
138
+ let pending = null;
139
+ nativeAdd.call(document, 'click', (e) => {
140
+ const a = inStage(e, 'a[href]');
141
+ const inert = inStage(e, '[data-kit-inert]');
142
+ if (!a && !inert) return;
143
+ if (a) e.preventDefault();
144
+ pending = { e, n: toasted, text: a ? `Prototype: this link opens ${a.getAttribute('href')}` : 'Prototype: nothing happens here.' };
145
+ }, true);
146
+ nativeAdd.call(document, 'submit', (e) => {
147
+ if (!inStage(e, 'form')) return;
148
+ e.preventDefault();
149
+ pending = { e, n: toasted, text: 'Prototype: nothing is sent.' };
150
+ }, true);
151
+ for (const type of ['click', 'submit']) {
152
+ nativeAdd.call(window, type, (e) => { if (pending?.e === e && pending.n === toasted) toast(pending.text); pending = null; });
153
+ }
154
+
155
+ async function render(key) {
156
+ const n = ++shown;
157
+ live = false;
158
+ try { cleanup?.(); } catch (err) { console.error(err); }
159
+ cleanup = null;
160
+ ends.splice(0).forEach((end) => end());
161
+ // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn.
162
+ const old = document.getElementById('stage');
163
+ const stage = old.cloneNode(false);
164
+ const y = window.scrollY;
165
+ stage.style.minHeight = `${old.offsetHeight}px`;
166
+ old.replaceWith(stage);
167
+ const settle = () => { if (n !== shown) return; stage.style.minHeight = ''; window.scrollTo(0, y); };
168
+ document.documentElement.dataset.take = key;
169
+ const take = data.takes.find((t) => t.key === key);
170
+ if (!take.src) {
171
+ stage.innerHTML = `<p class="kit-missing">Take ${esc(key)} has no file yet: write public/${esc(take.file)}, exporting render(stage, kit).</p>`;
172
+ settle();
173
+ return;
174
+ }
175
+ live = true;
176
+ try {
177
+ const mod = await import(new URL(take.src, location.href).href);
178
+ if (n !== shown) return;
179
+ const out = await mod.render(stage, kit);
180
+ if (n === shown) cleanup = typeof out === 'function' ? out : null;
181
+ else if (typeof out === 'function') out();
182
+ } catch (err) {
183
+ console.error(err);
184
+ if (n === shown) stage.innerHTML = `<p class="kit-missing">Take ${esc(key)} failed: ${esc(err?.message || err)}</p>`;
185
+ }
186
+ settle();
187
+ }
188
+
189
+ return PrototypeSwitcher.mount({ takes, initial: keys[0], render });
190
+ }
191
+
192
+ globalThis.PrototypeKit = { ...kit, start };
193
+ })();