@grainulation/mill 1.0.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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/bin/mill.js +320 -0
  5. package/lib/exporters/csv.js +83 -0
  6. package/lib/exporters/json-ld.js +44 -0
  7. package/lib/exporters/markdown.js +116 -0
  8. package/lib/exporters/pdf.js +104 -0
  9. package/lib/formats/bibtex.js +76 -0
  10. package/lib/formats/changelog.js +102 -0
  11. package/lib/formats/csv.js +92 -0
  12. package/lib/formats/dot.js +129 -0
  13. package/lib/formats/evidence-matrix.js +87 -0
  14. package/lib/formats/executive-summary.js +130 -0
  15. package/lib/formats/github-issues.js +89 -0
  16. package/lib/formats/graphml.js +118 -0
  17. package/lib/formats/html-report.js +181 -0
  18. package/lib/formats/jira-csv.js +89 -0
  19. package/lib/formats/json-ld.js +28 -0
  20. package/lib/formats/markdown.js +118 -0
  21. package/lib/formats/ndjson.js +25 -0
  22. package/lib/formats/obsidian.js +136 -0
  23. package/lib/formats/opml.js +108 -0
  24. package/lib/formats/ris.js +70 -0
  25. package/lib/formats/rss.js +100 -0
  26. package/lib/formats/sankey.js +72 -0
  27. package/lib/formats/slide-deck.js +200 -0
  28. package/lib/formats/sql.js +116 -0
  29. package/lib/formats/static-site.js +169 -0
  30. package/lib/formats/treemap.js +65 -0
  31. package/lib/formats/typescript-defs.js +147 -0
  32. package/lib/formats/yaml.js +144 -0
  33. package/lib/formats.js +60 -0
  34. package/lib/index.js +14 -0
  35. package/lib/json-ld-common.js +72 -0
  36. package/lib/publishers/clipboard.js +70 -0
  37. package/lib/publishers/static.js +152 -0
  38. package/lib/serve-mcp.js +340 -0
  39. package/lib/server.js +535 -0
  40. package/package.json +53 -0
  41. package/public/grainulation-tokens.css +321 -0
  42. package/public/index.html +891 -0
@@ -0,0 +1,891 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="auto" data-tool="mill">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Mill</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%230a0e1a'/><text x='32' y='34' text-anchor='middle' dominant-baseline='central' fill='%23a78bfa' font-family='-apple-system,system-ui,sans-serif' font-size='34' font-weight='800'>M</text></svg>">
8
+ <!-- STYLE 1: shared tokens -->
9
+ <style>
10
+ :root {
11
+ --bg: #0a0e1a; --bg2: #111827; --bg3: #1e293b; --bg4: #334155;
12
+ --fg: #e2e8f0; --fg2: #94a3b8; --fg3: #64748b;
13
+ --border: #1e293b; --border-subtle: rgba(255,255,255,0.08);
14
+ --green: #34d399; --red: #f87171; --blue: #60a5fa; --purple: #a78bfa; --orange: #fb923c; --cyan: #22d3ee;
15
+ --space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px; --space-2xl: 32px;
16
+ --radius: 8px; --radius-sm: 4px; --radius-lg: 12px;
17
+ --font-sans: -apple-system,BlinkMacSystemFont,'Segoe UI','Inter',sans-serif;
18
+ --font-mono: 'SF Mono','Cascadia Code','JetBrains Mono','Fira Code',monospace;
19
+ --text-xs: 9px; --text-sm: 10px; --text-base: 12px; --text-md: 13px; --text-lg: 15px; --text-xl: 18px;
20
+ --line-height: 1.5;
21
+ --transition-fast: 0.1s ease; --transition-base: 0.15s ease;
22
+ /* mill accent */
23
+ --accent: #a78bfa; --accent-light: #c4b5fd; --accent-dim: rgba(167,139,250,0.10); --accent-border: rgba(167,139,250,0.25);
24
+ }
25
+ *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+ html, body { height: 100%; overflow: hidden; }
27
+ body {
28
+ font-family: var(--font-sans); background: var(--bg); color: var(--fg);
29
+ background-image: radial-gradient(ellipse at 20% 50%, rgba(59,130,246,0.08) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(167,139,250,0.06) 0%, transparent 50%);
30
+ font-size: var(--text-md); line-height: var(--line-height);
31
+ -webkit-font-smoothing: antialiased;
32
+ }
33
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
34
+ ::-webkit-scrollbar-track { background: transparent; }
35
+ ::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
36
+ *:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
37
+ </style>
38
+ <!-- STYLE 2: shared layout -->
39
+ <style>
40
+ .app { display:grid; grid-template-columns:320px 1fr; grid-template-rows:auto 1fr auto; height:100vh; overflow:hidden }
41
+ .toolbar { grid-column:1/-1; display:flex; align-items:center; padding:4px var(--space-xl); border-bottom:1px solid var(--border); background:rgba(255,255,255,0.08); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); gap:10px }
42
+ .toolbar canvas { flex-shrink:0 }
43
+ .toolbar-spacer { flex:1 }
44
+ .toolbar-right { display:flex; align-items:center; gap:var(--space-sm) }
45
+ .status-dot { width:6px; height:6px; border-radius:50%; background:var(--fg3); flex-shrink:0; transition:background 0.3s,box-shadow 0.3s }
46
+ .status-dot.ok { background:var(--green); box-shadow:0 0 6px rgba(52,199,89,0.5) }
47
+ .reconnect-banner { position:fixed;top:0;left:0;right:0;z-index:9999;padding:8px 16px;background:#92400e;color:#fbbf24;font-size:12px;text-align:center;transform:translateY(-100%);transition:transform .3s;font-family:system-ui,sans-serif }
48
+ .reconnect-banner.visible { transform:translateY(0) }
49
+ .reconnect-banner button { background:none;border:1px solid #fbbf24;color:#fbbf24;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;margin-inline-start:8px }
50
+ .sidebar { background:var(--bg2); border-inline-end:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden }
51
+ .content { display:flex; flex-direction:column; overflow:hidden; min-height:0 }
52
+ .footer { grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; padding:var(--space-xs) var(--space-xl); border-top:1px solid var(--border); background:var(--bg2); font-size:var(--text-xs); color:var(--fg3) }
53
+ .footer-links { display:flex; gap:var(--space-lg) }
54
+ .footer a { color:var(--fg3); text-decoration:none; transition:color var(--transition-fast) }
55
+ .footer a:hover { color:var(--accent) }
56
+ .mobile-nav { display:none; grid-column:1/-1; background:var(--bg2); border-bottom:1px solid var(--border) }
57
+ .mobile-nav-bar { display:flex }
58
+ .mobile-tab { flex:1; padding:12px 0; text-align:center; font-size:12px; font-weight:600; color:var(--fg3); background:none; border:none; border-bottom:2px solid transparent; cursor:pointer; font-family:var(--font-sans) }
59
+ .mobile-tab:hover { color:var(--fg2) }
60
+ .mobile-tab.active { color:var(--accent); border-bottom-color:var(--accent) }
61
+ .welcome { padding:var(--space-xl); max-width:600px; margin:0 auto }
62
+ .welcome h2 { font-size:18px; font-weight:700; color:var(--fg); margin-bottom:var(--space-sm) }
63
+ .welcome .subtitle { font-size:13px; color:var(--fg2); line-height:1.6; margin-bottom:var(--space-xl) }
64
+ .welcome-section { margin-bottom:var(--space-xl) }
65
+ .welcome-section h3 { font-size:11px; text-transform:uppercase; letter-spacing:0.8px; color:var(--fg3); margin-bottom:var(--space-md) }
66
+ .welcome-step { display:flex; gap:var(--space-md); align-items:flex-start; margin-bottom:var(--space-md) }
67
+ .welcome-step-num { width:22px; height:22px; border-radius:50%; background:var(--accent-dim); border:1px solid var(--accent-border); color:var(--accent); font-size:11px; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0 }
68
+ .welcome-step-text { font-size:12px; color:var(--fg2); line-height:1.5 }
69
+ .welcome-step-text strong { color:var(--fg); font-weight:600 }
70
+ .welcome-kbd { display:inline-block; padding:1px 5px; background:var(--bg3); border:1px solid var(--border); border-radius:var(--radius-sm); font-family:var(--font-mono); font-size:10px; color:var(--fg2) }
71
+ .spinner { width:14px; height:14px; border:2px solid var(--bg4); border-top-color:var(--accent); border-radius:50%; animation:spin 0.6s linear infinite; display:inline-block }
72
+ @keyframes spin { to { transform:rotate(360deg) } }
73
+ .toast-container { position:fixed; bottom:var(--space-xl); right:var(--space-xl); z-index:100; display:flex; flex-direction:column; gap:var(--space-sm) }
74
+ .toast { padding:var(--space-sm) var(--space-lg); background:var(--bg3); border:1px solid var(--accent-border); border-radius:var(--radius); font-size:var(--text-sm); color:var(--fg); animation:slideIn 0.2s ease }
75
+ @keyframes slideIn { from { opacity:0; transform:translateY(8px) } to { opacity:1; transform:translateY(0) } }
76
+ .skip-link { position:absolute; top:-40px; inset-inline-start:0; background:var(--accent); color:#000; padding:8px 16px; z-index:10000; font-size:14px; font-weight:600; transition:top .2s }
77
+ .skip-link:focus { top:0 }
78
+ .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); border:0 }
79
+ @media (max-width:768px) {
80
+ .app { grid-template-columns:1fr; grid-template-rows:auto auto auto 1fr auto }
81
+ .toolbar { grid-column:1 }
82
+ .mobile-nav { display:block }
83
+ .sidebar { border-inline-end:none; border-bottom:1px solid var(--border); display:none }
84
+ .sidebar.mobile-visible { display:flex; max-height:none }
85
+ .content.mobile-hidden { display:none }
86
+ .footer { grid-column:1 }
87
+ }
88
+ @media (prefers-reduced-motion:reduce) {
89
+ *,*::before,*::after { animation-duration:0.01ms !important; transition-duration:0.01ms !important; scroll-behavior:auto !important }
90
+ }
91
+ /* -- sidebar items -- */
92
+ .search-box { padding:var(--space-md); border-bottom:1px solid var(--border) }
93
+ .search-box input { width:100%; padding:var(--space-sm) var(--space-md); border-radius:var(--radius); background:var(--bg3); border:1px solid var(--border); color:var(--fg); font-size:16px; font-family:var(--font-sans); outline:none; transition:border-color var(--transition-fast) }
94
+ .search-box input::placeholder { color:var(--fg3) }
95
+ .search-box input:focus { border-color:var(--accent-border) }
96
+ .search-box .search-label { display:block; font-size:var(--text-xs); font-weight:600; text-transform:uppercase; letter-spacing:0.1em; color:var(--fg3); padding:0 0 var(--space-xs) }
97
+ .search-box .search-count { font-size:var(--text-xs); color:var(--fg3); padding-top:var(--space-xs) }
98
+ .item-list { flex:1; overflow-y:auto; padding:var(--space-sm) }
99
+ .item-card { padding:var(--space-md); margin-bottom:var(--space-sm); border-radius:var(--radius); background:var(--bg); border:1px solid var(--border); cursor:pointer; transition:border-color var(--transition-fast),background var(--transition-fast) }
100
+ .item-card:hover { border-color:var(--accent-border); background:var(--accent-dim) }
101
+ .item-card.active { border-color:var(--accent); background:var(--accent-dim) }
102
+ .item-name { font-size:var(--text-md); font-weight:600; color:var(--fg) }
103
+ .item-desc { font-size:11px; color:var(--fg2); line-height:1.4; margin-top:2px }
104
+ .item-meta { display:flex; gap:var(--space-sm); flex-wrap:wrap; margin-top:var(--space-xs) }
105
+ .item-badge { font-size:var(--text-xs); padding:1px 6px; border-radius:var(--radius-sm); background:var(--bg3); color:var(--fg3); border:1px solid var(--border) }
106
+ .item-badge.feature { color:var(--blue); background:rgba(59,130,246,0.08); border-color:rgba(59,130,246,0.15); cursor:pointer }
107
+ .item-badge.feature:hover { background:rgba(59,130,246,0.18); border-color:rgba(59,130,246,0.3) }
108
+ .item-badge.feature.active-filter { background:rgba(59,130,246,0.25); border-color:var(--blue) }
109
+ .item-no-results { padding:var(--space-xl); text-align:center; color:var(--fg3); font-size:var(--text-sm) }
110
+ </style>
111
+ <!-- STYLE 3: mill-specific -->
112
+ <style>
113
+ .format-detail { display:flex; flex-wrap:wrap; gap:var(--space-md); margin-bottom:var(--space-lg); padding:var(--space-md) var(--space-lg); background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); font-size:var(--text-sm); color:var(--fg2); line-height:1.5 }
114
+ .format-detail-desc { flex:1 1 100%; color:var(--fg); font-size:var(--text-md) }
115
+ .format-detail-meta { display:flex; flex-wrap:wrap; gap:var(--space-sm) var(--space-lg); font-size:var(--text-xs); color:var(--fg3) }
116
+ .format-detail-tag { display:inline-flex; align-items:center; gap:3px; padding:2px 8px; background:var(--bg3); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); white-space:nowrap }
117
+ .format-detail-tag a { color:var(--blue); text-decoration:none }
118
+ .format-detail-tag a:hover { text-decoration:underline }
119
+ .content-header { display:flex; align-items:center; justify-content:space-between; padding:var(--space-md) var(--space-xl); border-bottom:1px solid var(--border); background:var(--bg); min-height:46px }
120
+ .content-title { font-size:var(--text-lg); font-weight:600 }
121
+ .content-actions { display:flex; gap:var(--space-sm) }
122
+ .btn { display:inline-flex; align-items:center; justify-content:center; gap:var(--space-xs); padding:var(--space-xs) var(--space-md); border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg3); color:var(--fg); font-size:var(--text-sm); font-family:var(--font-sans); cursor:pointer; transition:all var(--transition-base); white-space:nowrap }
123
+ .btn:hover { border-color:var(--accent-border); background:var(--accent-dim) }
124
+ .btn:disabled { opacity:0.4; cursor:not-allowed }
125
+ .btn-primary { background:var(--accent); color:var(--bg); border-color:var(--accent); font-weight:600 }
126
+ .btn-primary:hover { background:var(--accent-light); border-color:var(--accent-light) }
127
+ .btn-primary:disabled { opacity:0.4 }
128
+ .preview-area { flex:1; overflow:auto; padding:var(--space-xl) }
129
+ .preview-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--fg3); text-align:center; gap:var(--space-md) }
130
+ .preview-empty-icon { font-size:48px; opacity:0.3 }
131
+ .preview-empty-text { font-size:var(--text-lg); color:var(--fg2) }
132
+ .preview-empty-hint { font-size:var(--text-sm); color:var(--fg3); max-width:340px; line-height:1.5 }
133
+ .welcome-cat { margin-bottom:var(--space-sm) }
134
+ .welcome-cat-label { font-size:11px; font-weight:600; color:var(--fg3); margin-bottom:var(--space-xs) }
135
+ .welcome-cat-items { font-size:11px; color:var(--fg2); line-height:1.6 }
136
+ .preview-output { font-family:var(--font-mono); font-size:var(--text-base); line-height:1.6; color:var(--fg); white-space:pre-wrap; word-break:break-word; background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:var(--space-xl); min-height:200px }
137
+ .preview-stats { display:flex; gap:var(--space-xl); margin-bottom:var(--space-lg); padding:var(--space-md) var(--space-lg); background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius) }
138
+ .preview-stat { display:flex; flex-direction:column; gap:2px }
139
+ .preview-stat-label { font-size:var(--text-xs); color:var(--fg3); text-transform:uppercase; letter-spacing:0.5px }
140
+ .preview-stat-value { font-size:var(--text-md); font-weight:600; color:var(--fg) }
141
+ .preview-loading { display:flex; align-items:center; justify-content:center; height:100%; gap:var(--space-sm); color:var(--fg3) }
142
+ .history-panel { border-top:1px solid var(--border); background:var(--bg2); max-height:160px; overflow:auto }
143
+ .history-header { display:flex; align-items:center; justify-content:space-between; padding:var(--space-sm) var(--space-xl); border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg2); z-index:1 }
144
+ .history-title { font-size:var(--text-xs); font-weight:600; text-transform:uppercase; letter-spacing:0.8px; color:var(--fg3) }
145
+ .history-toggle { font-size:var(--text-xs); color:var(--accent); cursor:pointer; background:none; border:none; font-family:var(--font-sans) }
146
+ .history-table { width:100%; border-collapse:collapse }
147
+ .history-table th { padding:var(--space-xs) var(--space-xl); font-size:var(--text-xs); font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--fg3); text-align:start; border-bottom:1px solid var(--border) }
148
+ .history-table td { padding:var(--space-sm) var(--space-xl); font-size:var(--text-sm); border-bottom:1px solid var(--border-subtle) }
149
+ .history-table tr:last-child td { border-bottom:none }
150
+ .history-format { font-family:var(--font-mono); color:var(--accent); font-weight:500 }
151
+ .history-time { color:var(--fg3); font-size:var(--text-xs) }
152
+ .history-size { color:var(--fg2); text-align:end }
153
+ .history-empty { padding:var(--space-xl); text-align:center; color:var(--fg3); font-size:var(--text-sm) }
154
+ @media (max-width:768px) {
155
+ .preview-stats { flex-wrap:wrap; gap:var(--space-md) }
156
+ .footer { font-size:8px; padding:var(--space-xs) var(--space-md) }
157
+ }
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <a href="#main-content" class="skip-link">Skip to main content</a>
162
+ <div id="live-status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
163
+ <div class="reconnect-banner" id="reconnectBanner" role="status" aria-live="polite"></div>
164
+ <div class="app">
165
+ <header class="toolbar" role="banner">
166
+ <canvas id="grainLogo" width="256" height="256"></canvas>
167
+ <div class="toolbar-spacer"></div>
168
+ <div class="toolbar-right">
169
+ <div class="status-dot" id="sse-dot" aria-label="Connection status" role="status"></div>
170
+ </div>
171
+ </header>
172
+ <nav class="mobile-nav" aria-label="Mobile navigation">
173
+ <div class="mobile-nav-bar">
174
+ <button class="mobile-tab active" data-panel="content">Preview</button>
175
+ <button class="mobile-tab" data-panel="sidebar">Formats</button>
176
+ </div>
177
+ </nav>
178
+ <aside class="sidebar" aria-label="Format list">
179
+ <div class="search-box">
180
+ <input type="search" id="searchInput" placeholder="Filter formats..." aria-label="Filter formats" />
181
+ </div>
182
+ <div class="item-list" id="format-list" role="listbox" aria-label="Available formats"></div>
183
+ </aside>
184
+ <main class="content" id="main-content" aria-label="Mill workspace">
185
+ <div class="content-header" id="content-header" style="display:none">
186
+ <div class="content-title" id="content-title"></div>
187
+ <div class="content-actions" id="content-actions">
188
+ <button class="btn" id="btn-copy">Copy</button>
189
+ <button class="btn btn-primary" id="btn-download">Download</button>
190
+ </div>
191
+ </div>
192
+ <div class="preview-area" id="preview-area">
193
+ <div class="welcome" id="mill-welcome"><h2>Mill</h2><div class="subtitle">Loading formats...</div></div>
194
+ </div>
195
+ <div class="history-panel" id="history-panel">
196
+ <div class="history-header">
197
+ <span class="history-title">Export History</span>
198
+ <button class="history-toggle" id="history-toggle">clear</button>
199
+ </div>
200
+ <div class="history-list" id="history-list">
201
+ <div class="history-empty">No exports yet</div>
202
+ </div>
203
+ </div>
204
+ </main>
205
+ <footer class="footer">
206
+ <span>mill v1.0.0 -- @grainulation/mill</span>
207
+ <div class="footer-links">
208
+ <a href="http://localhost:9091">wheat</a>
209
+ <a href="http://localhost:9093">barn</a>
210
+ <a href="http://localhost:9095">silo</a>
211
+ <a href="http://localhost:9090">farmer</a>
212
+ <a href="http://localhost:9096">harvest</a>
213
+ <span style="color:var(--border)">&middot;</span>
214
+ <a href="https://github.com/grainulation/mill" target="_blank" rel="noopener noreferrer">github</a>
215
+ </div>
216
+ </footer>
217
+ </div>
218
+ <div class="toast-container" id="toast-container" aria-live="polite" role="status"></div>
219
+
220
+ <!-- SCRIPT 1: shared helpers + SSE + mobile nav -->
221
+ <script>
222
+ // -- Shared: helpers --
223
+ var $ = function(id) { return document.getElementById(id); };
224
+ function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
225
+
226
+ // -- Shared: SSE with exponential backoff --
227
+ var sseRetryCount = 0;
228
+ var sseSource = null;
229
+ function showBanner(count) {
230
+ var b = $('reconnectBanner');
231
+ if (count > 5) {
232
+ b.textContent = '';
233
+ b.appendChild(document.createTextNode('Connection lost. '));
234
+ var btn = document.createElement('button');
235
+ btn.textContent = 'Retry now';
236
+ btn.addEventListener('click', function() { sseRetryCount = 0; connectSSE(); });
237
+ b.appendChild(btn);
238
+ } else if (count > 1) {
239
+ b.textContent = 'Reconnecting (attempt ' + count + ')...';
240
+ } else {
241
+ b.textContent = 'Reconnecting...';
242
+ }
243
+ b.classList.add('visible');
244
+ }
245
+ function hideBanner() { $('reconnectBanner').classList.remove('visible'); }
246
+
247
+ function connectSSE() {
248
+ sseSource = new EventSource('/events');
249
+ sseSource.onopen = function() {
250
+ sseRetryCount = 0;
251
+ $('sse-dot').className = 'status-dot ok';
252
+ if (window._grainSetState) window._grainSetState('idle');
253
+ };
254
+ sseSource.onerror = function() {
255
+ sseSource.close();
256
+ $('sse-dot').className = 'status-dot';
257
+ if (window._grainSetState) window._grainSetState('orbit');
258
+ var delay = Math.min(30000, 1000 * Math.pow(2, sseRetryCount)) + Math.random() * 1000;
259
+ sseRetryCount++;
260
+ setTimeout(connectSSE, delay);
261
+ };
262
+ sseSource.onmessage = function(e) {
263
+ try {
264
+ var msg = JSON.parse(e.data);
265
+ onSSEMessage(msg);
266
+ } catch(ex) {}
267
+ };
268
+ }
269
+
270
+ // -- Shared: mobile panel switching --
271
+ function switchMobilePanel(panel) {
272
+ var sidebar = document.querySelector('.sidebar');
273
+ var content = document.querySelector('.content');
274
+ document.querySelectorAll('.mobile-tab').forEach(function(tb) { tb.classList.toggle('active', tb.dataset.panel === panel); });
275
+ sidebar.classList.remove('mobile-visible');
276
+ content.classList.remove('mobile-hidden');
277
+ if (panel === 'sidebar') { sidebar.classList.add('mobile-visible'); content.classList.add('mobile-hidden'); }
278
+ }
279
+ document.querySelector('.mobile-nav-bar').addEventListener('click', function(e) {
280
+ var tab = e.target.closest('[data-panel]');
281
+ if (tab) switchMobilePanel(tab.dataset.panel);
282
+ });
283
+ </script>
284
+
285
+ <!-- SCRIPT 2: mill-specific logic -->
286
+ <script>
287
+ // -- Mill helpers --
288
+ var _dtf = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
289
+ function formatBytes(bytes) {
290
+ if (bytes == null) return '--';
291
+ if (bytes < 1024) return bytes + ' B';
292
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
293
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
294
+ }
295
+ function formatTime(iso) {
296
+ if (!iso) return '--';
297
+ try { return _dtf.format(new Date(iso)); } catch(ex) { return iso; }
298
+ }
299
+ function formatLabel(name) {
300
+ if (!name) return '';
301
+ var labels = {
302
+ 'json-ld': 'JSON-LD', 'csv': 'CSV', 'ndjson': 'NDJSON', 'sql': 'SQL',
303
+ 'html-report': 'HTML Report', 'executive-summary': 'Executive Summary',
304
+ 'slide-deck': 'Slide Deck', 'evidence-matrix': 'Evidence Matrix',
305
+ 'jira-csv': 'Jira CSV', 'github-issues': 'GitHub Issues',
306
+ 'html-treemap': 'Treemap', 'html-sankey': 'Sankey', 'json': 'JSON',
307
+ 'yaml': 'YAML', 'pdf': 'PDF', 'typescript-defs': 'TypeScript Defs',
308
+ 'graphml': 'GraphML', 'dot': 'DOT (Graphviz)', 'static-site': 'Static Site',
309
+ };
310
+ return labels[name] || name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
311
+ }
312
+
313
+ // -- Category assignments --
314
+ var CATEGORIES = [
315
+ { id: 'document', label: 'Document', formats: ['markdown', 'html-report', 'executive-summary', 'slide-deck', 'pdf', 'changelog'] },
316
+ { id: 'data', label: 'Data', formats: ['csv', 'json', 'json-ld', 'ndjson', 'yaml', 'sql', 'typescript-defs'] },
317
+ { id: 'research', label: 'Research', formats: ['evidence-matrix', 'bibtex', 'ris', 'rss', 'opml', 'obsidian'] },
318
+ { id: 'visual', label: 'Visual', formats: ['graphml', 'dot', 'treemap', 'sankey', 'static-site'] },
319
+ { id: 'integrate', label: 'Integrate', formats: ['jira-csv', 'github-issues'] },
320
+ ];
321
+
322
+ // -- Format context: where each format goes, what opens it, docs links --
323
+ var FORMAT_CONTEXT = {
324
+ 'markdown': { use: 'README, wiki, docs', opens: 'Any text editor, GitHub, GitLab', docs: 'https://commonmark.org/help/' },
325
+ 'html-report': { use: 'Email, share in browser', opens: 'Any browser -- self-contained, no server needed' },
326
+ 'executive-summary': { use: 'Stakeholder updates, decision docs', opens: 'Browser, print to PDF' },
327
+ 'slide-deck': { use: 'Presentations, sprint reviews', opens: 'Browser -- keyboard nav built in' },
328
+ 'pdf': { use: 'Formal deliverables', opens: 'Any PDF reader', needs: 'pandoc or weasyprint' },
329
+ 'changelog': { use: 'Release notes, sprint history', opens: 'Text editor, GitHub releases' },
330
+ 'csv': { use: 'Spreadsheet analysis', opens: 'Excel, Google Sheets, Numbers', docs: 'https://support.google.com/docs/answer/40608' },
331
+ 'json': { use: 'API payloads, programmatic access', opens: 'Any JSON viewer, jq' },
332
+ 'json-ld': { use: 'Semantic web, structured data', opens: 'JSON-LD Playground, Google Rich Results', docs: 'https://json-ld.org/' },
333
+ 'ndjson': { use: 'Streaming, log pipelines', opens: 'jq, ndjson-cli, Elasticsearch bulk API', docs: 'https://ndjson.org/' },
334
+ 'yaml': { use: 'Config files, CI/CD', opens: 'Any text editor, Kubernetes, Ansible' },
335
+ 'sql': { use: 'Database import', opens: 'SQLite, PostgreSQL, MySQL, DBeaver' },
336
+ 'typescript-defs': { use: 'Type-safe integration', opens: 'VS Code, TypeScript compiler' },
337
+ 'evidence-matrix': { use: 'Gap analysis, audit trail', opens: 'Excel, Google Sheets -- pivot table ready' },
338
+ 'bibtex': { use: 'Academic citations', opens: 'LaTeX, Overleaf, Zotero, Mendeley', docs: 'https://www.bibtex.org/' },
339
+ 'ris': { use: 'Reference management', opens: 'Zotero, Mendeley, EndNote, Paperpile', docs: 'https://en.wikipedia.org/wiki/RIS_(file_format)' },
340
+ 'rss': { use: 'Feed readers, notifications', opens: 'Feedly, Inoreader, Thunderbird', docs: 'https://www.rssboard.org/rss-specification' },
341
+ 'opml': { use: 'Outliners, feed bundles', opens: 'OmniOutliner, Feedly (import), WorkFlowy', docs: 'https://opml.org/spec2.opml' },
342
+ 'obsidian': { use: 'Knowledge base, PKM', opens: 'Obsidian -- paste into vault, wikilinks work', docs: 'https://help.obsidian.md/' },
343
+ 'graphml': { use: 'Network analysis', opens: 'yEd, Gephi, Cytoscape', docs: 'http://graphml.graphdrawing.org/' },
344
+ 'dot': { use: 'Graph visualization', opens: 'Graphviz, VS Code (preview ext), edotor.net', docs: 'https://graphviz.org/doc/info/lang.html' },
345
+ 'treemap': { use: 'Proportional visualization', opens: 'D3.js, Observable, any JSON consumer' },
346
+ 'sankey': { use: 'Flow visualization (type -> evidence -> status)', opens: 'D3.js, Google Charts, SankeyMATIC' },
347
+ 'static-site': { use: 'Publish as website', opens: 'Hugo -- drop into content/, run hugo serve', docs: 'https://gohugo.io/getting-started/quick-start/' },
348
+ 'jira-csv': { use: 'Import claims as Jira issues', opens: 'Jira Cloud (External system import), Jira Server', docs: 'https://support.atlassian.com/jira-cloud-administration/docs/import-data-from-a-csv-file/' },
349
+ 'github-issues': { use: 'Import claims as GitHub issues', opens: 'gh issue create, GitHub API', docs: 'https://docs.github.com/en/issues' },
350
+ };
351
+
352
+ // -- State --
353
+ var state = null;
354
+ var selectedFormat = null;
355
+ var currentPreview = null;
356
+ var history = [];
357
+ // -- SSE message handler (called by shared SSE) --
358
+ function onSSEMessage(msg) {
359
+ if (msg.type === 'state' || msg.type === 'source-changed') {
360
+ state = msg.data;
361
+ renderState();
362
+ var ls = $('live-status');
363
+ if (ls) ls.textContent = 'Updated: ' + (state.claimCount || 0) + ' claims loaded';
364
+ }
365
+ if (msg.type === 'export-complete') { addHistoryItem(msg.data); }
366
+ }
367
+
368
+ // -- Render format list --
369
+ function renderState() {
370
+ if (!state) return;
371
+ var _cc = $('claim-count'); if (_cc) _cc.textContent = state.claimCount || 0;
372
+ renderFormats(state.formats || []);
373
+ if (!selectedFormat) renderWelcome();
374
+ }
375
+
376
+ function renderFormats(formats) {
377
+ var container = $('format-list');
378
+ var query = ($('searchInput').value || '').toLowerCase().trim();
379
+ container.innerHTML = '';
380
+
381
+ var fmtMap = {};
382
+ for (var f of formats) { fmtMap[f.name] = f; }
383
+
384
+ // Flatten all categories into one list, preserving category order
385
+ var allItems = [];
386
+ for (var cat of CATEGORIES) {
387
+ for (var name of cat.formats) {
388
+ if (fmtMap[name]) allItems.push({ name: name, fmt: fmtMap[name], cat: cat.label });
389
+ }
390
+ }
391
+
392
+ // Add uncategorized formats
393
+ var categorized = new Set();
394
+ for (var cat2 of CATEGORIES) { for (var n of cat2.formats) { categorized.add(n); } }
395
+ for (var uf of formats) {
396
+ if (!categorized.has(uf.name)) allItems.push({ name: uf.name, fmt: uf, cat: 'Other' });
397
+ }
398
+
399
+ // Filter
400
+ var filtered = allItems.filter(function(item) {
401
+ if (!query) return true;
402
+ return item.name.toLowerCase().includes(query)
403
+ || (item.fmt.description || '').toLowerCase().includes(query)
404
+ || (item.fmt.extension || '').toLowerCase().includes(query)
405
+ || formatLabel(item.name).toLowerCase().includes(query);
406
+ });
407
+
408
+ if (filtered.length === 0) {
409
+ container.innerHTML = '<div class="item-no-results">No formats match your search</div>';
410
+ return;
411
+ }
412
+
413
+ for (var i = 0; i < filtered.length; i++) {
414
+ var item = filtered[i];
415
+ var ctx = FORMAT_CONTEXT[item.name] || {};
416
+ var hint = ctx.use || item.fmt.description || '';
417
+ var card = document.createElement('div');
418
+ card.className = 'item-card' + (selectedFormat === item.name ? ' active' : '');
419
+ card.setAttribute('role', 'option');
420
+ card.setAttribute('aria-selected', selectedFormat === item.name ? 'true' : 'false');
421
+ card.dataset.format = item.name;
422
+ card.innerHTML =
423
+ '<div class="item-name">' + esc(formatLabel(item.name)) + '</div>' +
424
+ '<div class="item-desc">' + esc(hint) + '</div>' +
425
+ '<div class="item-meta">' +
426
+ '<span class="item-badge">' + esc(item.fmt.extension) + '</span>' +
427
+ '<span class="item-badge">' + esc(item.cat) + '</span>' +
428
+ '</div>';
429
+ card.addEventListener('click', (function(n) {
430
+ return function() { selectFormat(n); };
431
+ })(item.name));
432
+ container.appendChild(card);
433
+ }
434
+ }
435
+
436
+ // -- Welcome page --
437
+ function renderWelcome() {
438
+ var formats = state?.formats || [];
439
+ var area = $('preview-area');
440
+
441
+ var catSummary = '';
442
+ for (var cat of CATEGORIES) {
443
+ var matching = cat.formats.filter(function(n) { return formats.some(function(f) { return f.name === n; }); });
444
+ if (matching.length === 0) continue;
445
+ catSummary += '<div class="welcome-cat">' +
446
+ '<div class="welcome-cat-label">' + esc(cat.label) + ' (' + matching.length + ')</div>' +
447
+ '<div class="welcome-cat-items">' + matching.map(function(n) { return esc(formatLabel(n)); }).join(', ') + '</div>' +
448
+ '</div>';
449
+ }
450
+
451
+ area.innerHTML =
452
+ '<div class="welcome">' +
453
+ '<h2>Mill</h2>' +
454
+ '<div class="subtitle">' +
455
+ 'Export your wheat sprint data in ' + formats.length + ' formats. ' +
456
+ 'Mill converts compilation data into documents, spreadsheets, citations, visualizations, ' +
457
+ 'and integrations for tools like Jira, GitHub, Obsidian, Zotero, and more.' +
458
+ '</div>' +
459
+
460
+ '<div class="welcome-section">' +
461
+ '<h3>How it works</h3>' +
462
+ '<div class="welcome-step">' +
463
+ '<span class="welcome-step-num">1</span>' +
464
+ '<div class="welcome-step-text"><strong>Pick a format</strong> -- choose from the sidebar. Formats are grouped by purpose: documents for stakeholders, data for pipelines, research for citations, visual for charts, integrate for project trackers.</div>' +
465
+ '</div>' +
466
+ '<div class="welcome-step">' +
467
+ '<span class="welcome-step-num">2</span>' +
468
+ '<div class="welcome-step-text"><strong>Preview instantly</strong> -- clicking a format loads the preview immediately. You see exactly what the output looks like, with format details and compatible tools.</div>' +
469
+ '</div>' +
470
+ '<div class="welcome-step">' +
471
+ '<span class="welcome-step-num">3</span>' +
472
+ '<div class="welcome-step-text"><strong>Download or copy</strong> -- use the toolbar buttons to download the file or copy to clipboard. Export history is tracked at the bottom.</div>' +
473
+ '</div>' +
474
+ '</div>' +
475
+
476
+ '<div class="welcome-section">' +
477
+ '<h3>Available formats</h3>' +
478
+ catSummary +
479
+ '</div>' +
480
+
481
+ '<div class="welcome-section">' +
482
+ '<h3>Keyboard shortcuts</h3>' +
483
+ '<div class="welcome-step">' +
484
+ '<div class="welcome-step-text">' +
485
+ '<span class="welcome-kbd">/</span> Focus search ' +
486
+ '&nbsp;&nbsp;<span class="welcome-kbd">Esc</span> Return to this page' +
487
+ '&nbsp;&nbsp;<span class="welcome-kbd">[</span> <span class="welcome-kbd">]</span> Switch panels' +
488
+ '</div>' +
489
+ '</div>' +
490
+ '</div>' +
491
+ '</div>';
492
+ }
493
+
494
+ // -- Format selection: click -> instant preview --
495
+ function selectFormat(name) {
496
+ selectedFormat = name;
497
+ document.title = 'Mill - ' + formatLabel(name);
498
+ renderFormats(state?.formats || []);
499
+ loadPreview(name);
500
+ }
501
+
502
+ // -- Preview --
503
+ async function loadPreview(formatName) {
504
+ $('content-header').style.display = 'flex';
505
+ $('content-title').textContent = formatLabel(formatName);
506
+ $('btn-copy').disabled = true;
507
+ $('btn-download').disabled = true;
508
+ $('preview-area').innerHTML = '<div class="preview-loading"><div class="spinner"></div> Loading preview...</div>';
509
+
510
+ try {
511
+ var url = '/api/preview?format=' + encodeURIComponent(formatName);
512
+ var resp = await fetch(url);
513
+ var data = await resp.json();
514
+
515
+ if (data.error) { showPreviewError(data.error); return; }
516
+
517
+ currentPreview = data;
518
+ renderPreview(data);
519
+ $('btn-copy').disabled = false;
520
+ $('btn-download').disabled = false;
521
+ var ls = $('live-status');
522
+ if (ls) ls.textContent = 'Preview loaded: ' + formatLabel(data.format) + ', ' + formatBytes(data.size);
523
+ } catch (err) {
524
+ showPreviewError(err.message);
525
+ }
526
+ }
527
+
528
+ function renderPreview(data) {
529
+ var area = $('preview-area');
530
+ var fmt = (state.formats || []).find(function(f) { return f.name === data.format; });
531
+ var ctx = FORMAT_CONTEXT[data.format] || {};
532
+
533
+ var detail = '<div class="format-detail">';
534
+ detail += '<div class="format-detail-desc">' + esc(fmt?.description || '') + '</div>';
535
+ detail += '<div class="format-detail-meta">';
536
+ if (ctx.opens) detail += '<span class="format-detail-tag">Opens with: ' + esc(ctx.opens) + '</span>';
537
+ if (ctx.needs) detail += '<span class="format-detail-tag">Requires: ' + esc(ctx.needs) + '</span>';
538
+ if (ctx.docs) detail += '<span class="format-detail-tag"><a href="' + esc(ctx.docs) + '" target="_blank" rel="noopener noreferrer">Docs</a></span>';
539
+ detail += '</div></div>';
540
+
541
+ var stats =
542
+ '<div class="preview-stats">' +
543
+ '<div class="preview-stat"><span class="preview-stat-label">Format</span><span class="preview-stat-value">' + esc(formatLabel(data.format)) + '</span></div>' +
544
+ '<div class="preview-stat"><span class="preview-stat-label">Extension</span><span class="preview-stat-value" style="font-family:var(--font-mono)">' + esc(fmt?.extension || '') + '</span></div>' +
545
+ '<div class="preview-stat"><span class="preview-stat-label">Size</span><span class="preview-stat-value">' + formatBytes(data.size) + '</span></div>' +
546
+ '<div class="preview-stat"><span class="preview-stat-label">Lines</span><span class="preview-stat-value">' + data.output.split('\n').length + '</span></div>' +
547
+ '</div>';
548
+
549
+ var escaped = data.output.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
550
+ area.innerHTML = detail + stats + '<pre class="preview-output">' + escaped + '</pre>';
551
+ }
552
+
553
+ function showPreviewError(message) {
554
+ $('preview-area').innerHTML =
555
+ '<div class="preview-empty">' +
556
+ '<div class="preview-empty-icon" style="color: var(--red);">!</div>' +
557
+ '<div class="preview-empty-text">' + esc(message) + '</div>' +
558
+ '</div>';
559
+ $('content-actions').style.display = 'none';
560
+ }
561
+
562
+ // -- Copy --
563
+ async function copyPreview() {
564
+ if (!currentPreview || !currentPreview.output) return;
565
+ try { await navigator.clipboard.writeText(currentPreview.output); } catch(ex) {
566
+ var ta = document.createElement('textarea');
567
+ ta.value = currentPreview.output; ta.style.position = 'fixed'; ta.style.left = '-9999px';
568
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
569
+ }
570
+ toast('Copied to clipboard');
571
+ addToHistory('clipboard');
572
+ }
573
+
574
+ // -- Download --
575
+ function downloadPreview() {
576
+ if (!currentPreview || !currentPreview.output) return;
577
+ var fmt = (state.formats || []).find(function(f) { return f.name === selectedFormat; });
578
+ var blob = new Blob([currentPreview.output], { type: fmt?.mimeType || 'text/plain' });
579
+ var url = URL.createObjectURL(blob);
580
+ var a = document.createElement('a');
581
+ a.href = url;
582
+ a.download = 'export' + (fmt?.extension || '.txt');
583
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
584
+ URL.revokeObjectURL(url);
585
+ toast('Downloaded ' + formatLabel(selectedFormat) + ' (' + formatBytes(currentPreview.size) + ')');
586
+ addToHistory('download');
587
+ }
588
+
589
+ function addToHistory(method) {
590
+ if (!currentPreview) return;
591
+ history.unshift({
592
+ format: currentPreview.format,
593
+ timestamp: new Date().toISOString(),
594
+ outputSize: currentPreview.size,
595
+ claimCount: state?.claimCount || 0,
596
+ method: method,
597
+ });
598
+ renderHistory();
599
+ }
600
+
601
+ // -- History --
602
+ function addHistoryItem(job) { history.unshift(job); renderHistory(); }
603
+
604
+ function renderHistory() {
605
+ var list = $('history-list');
606
+ if (history.length === 0) {
607
+ list.innerHTML = '<div class="history-empty">No exports yet</div>';
608
+ return;
609
+ }
610
+ list.innerHTML = '<table class="history-table" role="table"><thead><tr>' +
611
+ '<th scope="col">Format</th><th scope="col">Time</th><th scope="col">Size</th>' +
612
+ '</tr></thead><tbody>' +
613
+ history.slice(0, 20).map(function(job) {
614
+ return '<tr>' +
615
+ '<td class="history-format">' + esc(formatLabel(job.format)) + '</td>' +
616
+ '<td class="history-time">' + esc(formatTime(job.timestamp)) + '</td>' +
617
+ '<td class="history-size">' + esc(formatBytes(job.outputSize)) + '</td>' +
618
+ '</tr>';
619
+ }).join('') +
620
+ '</tbody></table>';
621
+ }
622
+
623
+ function clearHistory() { history = []; renderHistory(); }
624
+
625
+ // -- Toast --
626
+ function toast(message) {
627
+ var container = $('toast-container');
628
+ var el = document.createElement('div');
629
+ el.className = 'toast'; el.textContent = message;
630
+ container.appendChild(el);
631
+ setTimeout(function() { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s ease'; setTimeout(function() { el.remove(); }, 300); }, 3000);
632
+ }
633
+
634
+ // -- Event listeners --
635
+
636
+ // Search
637
+ $('searchInput').addEventListener('input', function() {
638
+ renderFormats(state?.formats || []);
639
+ });
640
+
641
+ // Mobile nav keyboard
642
+ document.querySelector('.mobile-nav-bar').addEventListener('keydown', function(e) {
643
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
644
+ e.preventDefault();
645
+ var panels = ['content', 'sidebar'];
646
+ var current = document.querySelector('.mobile-tab.active')?.dataset.panel || 'content';
647
+ var idx = panels.indexOf(current);
648
+ var next = panels[(idx + (e.key === 'ArrowRight' ? 1 : panels.length - 1)) % panels.length];
649
+ switchMobilePanel(next);
650
+ document.querySelector('.mobile-tab[data-panel="' + next + '"]')?.focus();
651
+ }
652
+ });
653
+
654
+ // Buttons
655
+ $('btn-copy').addEventListener('click', copyPreview);
656
+ $('btn-download').addEventListener('click', downloadPreview);
657
+ $('history-toggle').addEventListener('click', clearHistory);
658
+
659
+ // Keyboard
660
+ document.addEventListener('keydown', function(e) {
661
+ if (e.key === 'Escape' && !e.target.matches('input')) {
662
+ selectedFormat = null; currentPreview = null;
663
+ document.title = 'Mill';
664
+ renderFormats(state?.formats || []);
665
+ $('content-header').style.display = 'none';
666
+ renderWelcome();
667
+ var ls = $('live-status'); if (ls) ls.textContent = 'Selection cleared';
668
+ }
669
+ if (e.key === '/' && !e.target.matches('input,textarea,select')) {
670
+ e.preventDefault();
671
+ $('searchInput').focus();
672
+ }
673
+ if (e.key === '[' && !e.target.matches('input,textarea')) { e.preventDefault(); document.querySelector('.sidebar').setAttribute('tabindex','-1'); document.querySelector('.sidebar').focus(); }
674
+ if (e.key === ']' && !e.target.matches('input,textarea')) { e.preventDefault(); document.querySelector('.content').setAttribute('tabindex','-1'); document.querySelector('.content').focus(); }
675
+ });
676
+
677
+ // -- Init --
678
+ connectSSE();
679
+ fetch('/api/state').then(function(r) { return r.json(); }).then(function(data) { state = data; renderState(); }).catch(function() {});
680
+ </script>
681
+
682
+ <!-- SCRIPT 3: grain logo animation -->
683
+ <script>
684
+ (function() {
685
+ var LW = 0.025;
686
+ var TOOL = { name: 'Mill', letter: 'M', color: '#a78bfa' };
687
+ var _c, _ctx, _s, _cx, _textStart, _restText, _font;
688
+ var _state = 'drawon', _start = null, _raf, _pendingState = null;
689
+ var _openPts = null, _closedPts = null;
690
+
691
+ function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
692
+ function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
693
+
694
+ function _bracket(ctx, s, color, alpha) {
695
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
696
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
697
+ if(alpha!==undefined) ctx.globalAlpha=alpha;
698
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
699
+ ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
700
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
701
+ ctx.lineTo(cx,botY); ctx.stroke();
702
+ if(alpha!==undefined) ctx.globalAlpha=1;
703
+ }
704
+
705
+ function _drawBracket(ctx, s, color, progress) {
706
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
707
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
708
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
709
+ var seg1=0.12, seg2=0.72;
710
+ ctx.beginPath();
711
+ if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
712
+ else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
713
+ var bt=(progress-seg1)/seg2;
714
+ var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
715
+ var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
716
+ var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
717
+ ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
718
+ else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
719
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
720
+ ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
721
+ ctx.stroke();
722
+ }
723
+
724
+ function _drawName(ctx, s, spellP, alpha) {
725
+ var a = alpha !== undefined ? alpha : 1;
726
+ ctx.font = _font; ctx.textBaseline = 'middle';
727
+ var cy = s/2 + s*0.02;
728
+ ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
729
+ ctx.fillText(TOOL.letter, _cx, cy);
730
+ if(_restText.length > 0 && spellP > 0) {
731
+ var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
732
+ var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
733
+ var full = charP >= 1 ? num : num - 1;
734
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
735
+ if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
736
+ if(full < num) {
737
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
738
+ ctx.globalAlpha = a * (0.3 + 0.7 * charP);
739
+ ctx.fillText(_restText[full], _textStart + prevW, cy);
740
+ }
741
+ }
742
+ ctx.globalAlpha = 1;
743
+ }
744
+
745
+ function _getOpenPts(s) {
746
+ if(_openPts && _openPts._s === s) return _openPts;
747
+ var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
748
+ var pts=[];
749
+ for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
750
+ var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
751
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*p0.x+3*u*u*t*p1.x+3*u*t*t*p2.x+t*t*t*p3.x,y:u*u*u*p0.y+3*u*u*t*p1.y+3*u*t*t*p2.y+t*t*t*p3.y});}
752
+ for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
753
+ pts._s=s; _openPts=pts; return pts;
754
+ }
755
+
756
+ function _getClosedPts(s) {
757
+ if(_closedPts && _closedPts._s === s) return _closedPts;
758
+ var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
759
+ var pts=[];
760
+ for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
761
+ var lp0={x:cx-fe,y:topY},lp1={x:cx-gw*0.52,y:cy-gh*0.32},lp2={x:cx-gw*0.52,y:cy+gh*0.24},lp3={x:cx-fe,y:botY};
762
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*lp0.x+3*u*u*t*lp1.x+3*u*t*t*lp2.x+t*t*t*lp3.x,y:u*u*u*lp0.y+3*u*u*t*lp1.y+3*u*t*t*lp2.y+t*t*t*lp3.y});}
763
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
764
+ var rp0={x:cx+fe,y:botY},rp1={x:cx+gw*0.52,y:cy+gh*0.24},rp2={x:cx+gw*0.52,y:cy-gh*0.32},rp3={x:cx+fe,y:topY};
765
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*rp0.x+3*u*u*t*rp1.x+3*u*t*t*rp2.x+t*t*t*rp3.x,y:u*u*u*rp0.y+3*u*u*t*rp1.y+3*u*t*t*rp2.y+t*t*t*rp3.y});}
766
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
767
+ pts._s=s; _closedPts=pts; return pts;
768
+ }
769
+
770
+ function _frame(ts) {
771
+ if(!_c) return;
772
+ if(!_start) _start = ts;
773
+ var e = ts - _start, ctx = _ctx, s = _s;
774
+ ctx.clearRect(0, 0, _c.width, s);
775
+ switch(_state) {
776
+ case 'drawon':
777
+ var bp = _easeInOut(Math.min(1, e / 1400));
778
+ _drawBracket(ctx, s, TOOL.color, bp);
779
+ var la = Math.max(0, Math.min(1, (e - 900) / 400));
780
+ if(la > 0) {
781
+ ctx.font = _font; ctx.textBaseline = 'middle';
782
+ ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
783
+ ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
784
+ }
785
+ if(e > 1100 && _restText.length > 0) {
786
+ var sp = Math.min(1, (e - 1100) / (120 * _restText.length));
787
+ var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
788
+ if(num > 0) {
789
+ ctx.font = _font; ctx.textBaseline = 'middle';
790
+ var cy = s/2 + s*0.02, rawP = sp * n;
791
+ var charP = num >= n ? 1 : rawP - Math.floor(rawP);
792
+ var full = charP >= 1 ? num : num - 1;
793
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
794
+ if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
795
+ if(full < num) {
796
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
797
+ ctx.globalAlpha = 0.3 + 0.7 * charP;
798
+ ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
799
+ }
800
+ }
801
+ }
802
+ if(e > 1100 + 120 * _restText.length + 300) { _state = _pendingState || 'idle'; _pendingState = null; _start = ts; }
803
+ break;
804
+ case 'idle':
805
+ var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
806
+ _bracket(ctx, s, TOOL.color, breathe);
807
+ var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
808
+ _drawName(ctx, s, 1, textBreath);
809
+ break;
810
+ case 'shimmer':
811
+ _bracket(ctx, s, TOOL.color, 0.2);
812
+ var spts = _getOpenPts(s), sspeed = 1800;
813
+ var spos = (e % sspeed) / sspeed;
814
+ var sidx = Math.floor(spos * (spts.length - 1));
815
+ var spt = spts[sidx];
816
+ var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
817
+ sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
818
+ ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
819
+ var strailFrac = 0.18;
820
+ var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
821
+ ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
822
+ ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
823
+ for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
824
+ ctx.stroke(); ctx.globalAlpha = 1;
825
+ _drawName(ctx, s, 1, undefined);
826
+ break;
827
+ case 'orbit':
828
+ _bracket(ctx, s, TOOL.color, 0.15);
829
+ _drawName(ctx, s, 1, 0.4);
830
+ var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
831
+ var halfCycle = (e % speed) / speed;
832
+ var cycle = (e % (speed * 2)) / (speed * 2);
833
+ var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
834
+ var headIdx = Math.floor(pos * (pts.length - 1));
835
+ var trailLen = Math.floor(trailFrac * pts.length);
836
+ var dir = cycle < 0.5 ? 1 : -1;
837
+ ctx.lineWidth = s * LW; ctx.lineCap = 'round';
838
+ for(var i = 0; i < trailLen; i++) {
839
+ var idx = headIdx - dir * (trailLen - i);
840
+ if(idx < 0 || idx >= pts.length) continue;
841
+ var nxt = idx + dir;
842
+ if(nxt < 0 || nxt >= pts.length) continue;
843
+ ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
844
+ ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
845
+ }
846
+ ctx.globalAlpha = 1;
847
+ break;
848
+ case 'dim':
849
+ var dim = 0.1 + 0.08 * Math.sin(e / 2000);
850
+ _bracket(ctx, s, TOOL.color, dim);
851
+ _drawName(ctx, s, 1, 0.2);
852
+ break;
853
+ }
854
+ _raf = requestAnimationFrame(_frame);
855
+ }
856
+
857
+ _c = document.getElementById('grainLogo');
858
+ if(_c) {
859
+ _c.style.width = '0px';
860
+ _s = 256;
861
+ var targetFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
862
+ var fontRatio = 0.38;
863
+ var dh = 64;
864
+ _c.height = _s; _c.width = 1024;
865
+ _ctx = _c.getContext('2d');
866
+ _cx = _s / 2;
867
+ _restText = TOOL.name.slice(1);
868
+ _font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
869
+ _ctx.font = _font;
870
+ var letterW = _ctx.measureText(TOOL.letter).width;
871
+ var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
872
+ _textStart = _cx + letterW / 2 + _s * 0.02;
873
+ var totalW = Math.ceil(_textStart + restW + _s * 0.12);
874
+ _c.width = totalW;
875
+ _ctx = _c.getContext('2d');
876
+ _c.style.height = dh + 'px';
877
+ _c.style.width = Math.round(totalW / _s * dh) + 'px';
878
+ _state = 'drawon'; _start = null;
879
+ _raf = requestAnimationFrame(_frame);
880
+ }
881
+
882
+ window._grainSetState = function(state) {
883
+ if(_state === state) return;
884
+ if(_state === 'drawon') { _pendingState = state; return; }
885
+ _state = state; _start = null;
886
+ if(!_raf) _raf = requestAnimationFrame(_frame);
887
+ };
888
+ })();
889
+ </script>
890
+ </body>
891
+ </html>