syndes 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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell: unlock, top bar, page head, rail, and the router.
|
|
3
|
+
*
|
|
4
|
+
* The range lives here rather than in a view, because it is shared — moving
|
|
5
|
+
* from Overview to Habits must not silently reset the window you are reading.
|
|
6
|
+
*
|
|
7
|
+
* Views are imported lazily, so the first paint does not wait on the Ledger
|
|
8
|
+
* view's table code and a view that fails to load takes only itself down.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { api, ApiError, onBusy } from './api.js';
|
|
12
|
+
import { h, replace, mount, icon, dropdown } from './ui.js';
|
|
13
|
+
|
|
14
|
+
const ROUTES = [
|
|
15
|
+
{ id: 'overview', label: 'Overview', title: 'Overview', load: () => import('./views/overview.js') },
|
|
16
|
+
{ id: 'sessions', label: 'Sessions', title: 'Sessions', load: () => import('./views/sessions.js') },
|
|
17
|
+
{ id: 'habits', label: 'Habits', title: 'Habits', load: () => import('./views/habits.js') },
|
|
18
|
+
{ id: 'ledger', label: 'Ledger', title: 'Ledger', load: () => import('./views/ledger.js') },
|
|
19
|
+
// Reached from the rail, not the top nav: settings is somewhere you go once,
|
|
20
|
+
// and a fifth pill would dilute the four you use every day.
|
|
21
|
+
{ id: 'settings', label: 'Settings', title: 'Settings', rail: true, noRange: true, load: () => import('./views/settings.js') },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const RANGES = [
|
|
25
|
+
{ id: 'today', label: 'Today' },
|
|
26
|
+
{ id: '7d', label: '7 days' },
|
|
27
|
+
{ id: '30d', label: '30 days' },
|
|
28
|
+
{ id: '90d', label: '90 days' },
|
|
29
|
+
{ id: 'all', label: 'All time' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
range: localStorage.getItem('syndes.range') ?? '7d',
|
|
34
|
+
route: 'overview',
|
|
35
|
+
status: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const root = document.getElementById('root');
|
|
39
|
+
let outlet;
|
|
40
|
+
let titleNode;
|
|
41
|
+
let subNode;
|
|
42
|
+
let statusNode;
|
|
43
|
+
|
|
44
|
+
boot();
|
|
45
|
+
|
|
46
|
+
async function boot() {
|
|
47
|
+
try {
|
|
48
|
+
state.status = await api.status();
|
|
49
|
+
} catch {
|
|
50
|
+
return mount(root, notice('Cannot reach the dashboard server', 'Start it with: syndes dashboard'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The cheapest way to ask "am I already in" is to try something that needs it.
|
|
54
|
+
try {
|
|
55
|
+
await api.config();
|
|
56
|
+
renderApp();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof ApiError && error.status === 401) renderUnlock();
|
|
59
|
+
else mount(root, notice('Something went wrong', error.message));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Unlock ─────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async function renderUnlock(message = '') {
|
|
66
|
+
const auth = await api.unlockOptions().catch(() => ({ mode: 'open' }));
|
|
67
|
+
const error = h('div.unlock__error', { text: message, role: 'alert' });
|
|
68
|
+
|
|
69
|
+
const body = [];
|
|
70
|
+
|
|
71
|
+
if (auth.mode === 'system') {
|
|
72
|
+
const button = h('button.btn.btn--lime', { text: `Unlock with ${auth.system.method ?? 'your system'}` });
|
|
73
|
+
button.addEventListener('click', async () => {
|
|
74
|
+
button.disabled = true;
|
|
75
|
+
button.textContent = 'Waiting for you…';
|
|
76
|
+
try {
|
|
77
|
+
await api.unlock();
|
|
78
|
+
renderApp();
|
|
79
|
+
} catch (caught) {
|
|
80
|
+
error.textContent = caught.body?.error ?? 'Not authenticated.';
|
|
81
|
+
button.disabled = false;
|
|
82
|
+
button.textContent = `Unlock with ${auth.system.method ?? 'your system'}`;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
body.push(button, h('p.unlock__note', { text: 'Your Mac will ask for your fingerprint or login password. syndes never sees either.' }));
|
|
86
|
+
} else if (auth.mode === 'pin') {
|
|
87
|
+
const input = h('input.pin', { type: 'password', inputmode: 'numeric', autocomplete: 'off', maxlength: '12', autofocus: true });
|
|
88
|
+
const form = h('form', { style: 'display:grid;gap:16px;justify-items:center' }, [
|
|
89
|
+
input,
|
|
90
|
+
h('button.btn.btn--lime', { type: 'submit', text: 'Unlock' }),
|
|
91
|
+
]);
|
|
92
|
+
form.addEventListener('submit', async (event) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
try {
|
|
95
|
+
await api.unlock(input.value);
|
|
96
|
+
renderApp();
|
|
97
|
+
} catch (caught) {
|
|
98
|
+
const wait = caught.body?.retryAfterMs;
|
|
99
|
+
error.textContent = wait ? `Too many attempts — wait ${Math.ceil(wait / 1000)}s.` : 'Wrong PIN.';
|
|
100
|
+
input.value = '';
|
|
101
|
+
input.focus();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
body.push(form);
|
|
105
|
+
} else {
|
|
106
|
+
// Open mode reached without a launch token: the link carried the key.
|
|
107
|
+
body.push(
|
|
108
|
+
h('p.unlock__note', { text: 'SynDes opens from the command line, which puts a one-time key in the link.' }),
|
|
109
|
+
h('code', { style: 'font-family:var(--mono);font-size:13px;color:var(--lime)', text: 'syndes dashboard' }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mount(root, h('main.unlock', {}, [
|
|
114
|
+
h('div.unlock__box', {}, [
|
|
115
|
+
h('div.logo', { style: 'width:64px;height:64px' }, [h('img', { src: '/logo.png', alt: 'SynDes', width: 64, height: 64 })]),
|
|
116
|
+
h('h1.unlock__title', { text: 'SynDes' }),
|
|
117
|
+
h('p.unlock__note', { text: 'Your prompts, paths and commands. Local only.' }),
|
|
118
|
+
...body,
|
|
119
|
+
error,
|
|
120
|
+
]),
|
|
121
|
+
]));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── App ────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function renderApp() {
|
|
127
|
+
titleNode = h('h1.title');
|
|
128
|
+
subNode = h('div.title__sub');
|
|
129
|
+
statusNode = h('div.status');
|
|
130
|
+
outlet = h('div.content');
|
|
131
|
+
|
|
132
|
+
const nav = h('nav.nav', { 'aria-label': 'Sections' }, ROUTES.filter((route) => !route.rail).map((route) =>
|
|
133
|
+
h('button.navpill', { id: `nav-${route.id}`, onclick: () => go(route.id) }, [
|
|
134
|
+
h('span', { text: route.label }),
|
|
135
|
+
route.id === 'habits' ? h('span.navpill__dot', { id: 'nav-habits-count', hidden: true }) : null,
|
|
136
|
+
])));
|
|
137
|
+
|
|
138
|
+
mount(root, h('div.shell', {}, [
|
|
139
|
+
h('header.topbar', {}, [
|
|
140
|
+
h('div.logo', { title: 'SynDes' }, [h('img', { src: '/logo.png', alt: 'SynDes', width: 46, height: 46 })]),
|
|
141
|
+
nav,
|
|
142
|
+
h('div.topbar__right', {}, [statusNode]),
|
|
143
|
+
]),
|
|
144
|
+
|
|
145
|
+
h('div.pagehead', {}, [
|
|
146
|
+
h('div', {}, [titleNode, subNode]),
|
|
147
|
+
// The range filter is hidden where it would be a lie: nothing on Settings
|
|
148
|
+
// is scoped to a time window.
|
|
149
|
+
h('div.filters', { id: 'filters' }, [rangeFilter()]),
|
|
150
|
+
]),
|
|
151
|
+
|
|
152
|
+
h('div.body', {}, [
|
|
153
|
+
h('aside.rail', {}, [
|
|
154
|
+
railButton('refresh', 'Reload this view', () => render()),
|
|
155
|
+
railButton('verify', 'Check the chain', () => go('ledger')),
|
|
156
|
+
railButton('download', 'Export everything as CSV', () => { window.location.href = api.exportUrl('csv', state.range); }),
|
|
157
|
+
railButton('settings', 'Settings', () => go('settings'), 'settings'),
|
|
158
|
+
]),
|
|
159
|
+
outlet,
|
|
160
|
+
]),
|
|
161
|
+
]));
|
|
162
|
+
|
|
163
|
+
onBusy((busy) => { statusNode.style.opacity = busy ? '0.5' : '1'; });
|
|
164
|
+
window.addEventListener('hashchange', () => go(routeFromHash(), { push: false }));
|
|
165
|
+
go(routeFromHash(), { push: false });
|
|
166
|
+
refreshStatus();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function railButton(name, label, onClick, routeId = null) {
|
|
170
|
+
return h('button.railbtn', {
|
|
171
|
+
id: routeId ? `nav-${routeId}` : null,
|
|
172
|
+
title: label, 'aria-label': label, onclick: onClick,
|
|
173
|
+
}, [icon(name)]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rangeFilter() {
|
|
177
|
+
return dropdown({
|
|
178
|
+
label: 'Range',
|
|
179
|
+
value: state.range,
|
|
180
|
+
options: RANGES.map((range) => ({ id: range.id, label: range.label })),
|
|
181
|
+
onChange: setRange,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function setRange(id) {
|
|
186
|
+
state.range = id;
|
|
187
|
+
localStorage.setItem('syndes.range', id);
|
|
188
|
+
render();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function routeFromHash() {
|
|
192
|
+
const id = location.hash.replace(/^#\/?/, '').split('/')[0];
|
|
193
|
+
return ROUTES.some((route) => route.id === id) ? id : 'overview';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function go(id, { push = true } = {}) {
|
|
197
|
+
state.route = id;
|
|
198
|
+
if (push && location.hash !== `#/${id}`) location.hash = `#/${id}`;
|
|
199
|
+
for (const route of ROUTES) {
|
|
200
|
+
const node = document.getElementById(`nav-${route.id}`);
|
|
201
|
+
if (!node) continue;
|
|
202
|
+
if (route.id === id) node.setAttribute('aria-current', 'page');
|
|
203
|
+
else node.removeAttribute('aria-current');
|
|
204
|
+
}
|
|
205
|
+
render();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function render() {
|
|
209
|
+
const route = ROUTES.find((candidate) => candidate.id === state.route);
|
|
210
|
+
titleNode.textContent = route.title;
|
|
211
|
+
subNode.textContent = route.noRange ? 'Stored on this machine only' : (RANGES.find((range) => range.id === state.range)?.label ?? state.range);
|
|
212
|
+
const filters = document.getElementById('filters');
|
|
213
|
+
if (filters) filters.hidden = Boolean(route.noRange);
|
|
214
|
+
|
|
215
|
+
mount(outlet, skeleton());
|
|
216
|
+
try {
|
|
217
|
+
const module = await route.load();
|
|
218
|
+
const node = await module.render({ range: state.range, status: state.status, go });
|
|
219
|
+
// A range switch mid-load must not paint the answer to the previous question.
|
|
220
|
+
if (state.route === route.id) mount(outlet, node);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error instanceof ApiError && error.status === 401) return renderUnlock();
|
|
223
|
+
mount(outlet, notice(`Could not load ${route.label}`, error.message));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function refreshStatus() {
|
|
228
|
+
try {
|
|
229
|
+
const [verified, practices] = await Promise.all([api.verify(false), api.practices()]);
|
|
230
|
+
replace(statusNode,
|
|
231
|
+
h(`span.status__led${verified.ok ? '' : '.status__led--bad'}`),
|
|
232
|
+
h('span', { text: verified.ok ? `Chain verified · ${verified.records.toLocaleString()} records` : `${verified.problems.length} chain problems` }));
|
|
233
|
+
|
|
234
|
+
const live = practices.findings.filter((finding) => !finding.muted).length;
|
|
235
|
+
const badge = document.getElementById('nav-habits-count');
|
|
236
|
+
if (badge) {
|
|
237
|
+
badge.textContent = String(live);
|
|
238
|
+
badge.hidden = live === 0;
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// The status pill is a status pill. The app works without it.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function skeleton() {
|
|
246
|
+
return h('div.grid', {}, [
|
|
247
|
+
h('div.grid.g-3', {}, [0, 1, 2].map(() => h('div.skel', { style: 'height:300px' }))),
|
|
248
|
+
h('div.skel', { style: 'height:260px' }),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function notice(title, detail) {
|
|
253
|
+
return h('main.unlock', {}, [
|
|
254
|
+
h('div.unlock__box', {}, [
|
|
255
|
+
h('h1.unlock__title', { text: title }),
|
|
256
|
+
h('p.unlock__note', { text: detail }),
|
|
257
|
+
]),
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export { state };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charts, in the reference's visual language: everything is a capsule.
|
|
3
|
+
*
|
|
4
|
+
* Colour is never decoration here. Lime says healthy, orange says look at this,
|
|
5
|
+
* white says neutral volume — and each chart's legend states which is which, so
|
|
6
|
+
* the mapping is never something the reader has to infer.
|
|
7
|
+
*
|
|
8
|
+
* Hand-rolled SVG because the server's CSP blocks every CDN and a chart library
|
|
9
|
+
* would end the zero-dependency promise for five chart types.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
13
|
+
|
|
14
|
+
function el(name, attrs = {}, children = []) {
|
|
15
|
+
const node = document.createElementNS(NS, name);
|
|
16
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
17
|
+
if (value !== null && value !== undefined) node.setAttribute(key, String(value));
|
|
18
|
+
}
|
|
19
|
+
for (const child of [].concat(children)) if (child) node.appendChild(child);
|
|
20
|
+
return node;
|
|
21
|
+
}
|
|
22
|
+
const txt = (value) => document.createTextNode(String(value));
|
|
23
|
+
|
|
24
|
+
function frame(width, height, label, children) {
|
|
25
|
+
const node = el('svg', {
|
|
26
|
+
class: 'chart', viewBox: `0 0 ${width} ${height}`,
|
|
27
|
+
role: 'img', 'aria-label': label,
|
|
28
|
+
}, children);
|
|
29
|
+
node.style.aspectRatio = `${width} / ${height}`;
|
|
30
|
+
node.style.maxWidth = `${width}px`;
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TONE = { lime: 'cap-lime', orange: 'cap-orange', white: 'cap-white' };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Vertical capsules with the value written inside — the reference's signature
|
|
38
|
+
* chart. Used where each item is a discrete thing worth naming, not a
|
|
39
|
+
* continuous series: tool families, top files, hours of the day.
|
|
40
|
+
*
|
|
41
|
+
* @param {{label: string, value: number, tone: 'lime'|'orange'|'white'}[]} items
|
|
42
|
+
*/
|
|
43
|
+
export function capsules(items, { height = 210, label = 'distribution', slotWidth = 82 } = {}) {
|
|
44
|
+
// Width follows the item count. Stretching eight capsules across a wide card
|
|
45
|
+
// is fine; stretching three turns them into blobs, which reads as a design
|
|
46
|
+
// choice rather than as the data being sparse.
|
|
47
|
+
const width = Math.max(200, Math.min(640, items.length * slotWidth));
|
|
48
|
+
const max = Math.max(...items.map((item) => item.value), 1);
|
|
49
|
+
const slot = width / Math.max(items.length, 1);
|
|
50
|
+
const capWidth = Math.min(46, slot * 0.58);
|
|
51
|
+
const top = 14;
|
|
52
|
+
const floor = height - 34;
|
|
53
|
+
const usable = floor - top;
|
|
54
|
+
|
|
55
|
+
const nodes = [];
|
|
56
|
+
items.forEach((item, index) => {
|
|
57
|
+
const cx = index * slot + slot / 2;
|
|
58
|
+
// A stem behind every capsule keeps the baseline readable when a value is
|
|
59
|
+
// small; without it a short capsule floats with nothing to measure against.
|
|
60
|
+
nodes.push(el('line', { class: 'cap-stem', x1: cx, y1: top, x2: cx, y2: floor }));
|
|
61
|
+
|
|
62
|
+
const h = Math.max(capWidth, (item.value / max) * usable);
|
|
63
|
+
const y = floor - h;
|
|
64
|
+
nodes.push(el('rect', {
|
|
65
|
+
class: TONE[item.tone] ?? TONE.white,
|
|
66
|
+
x: cx - capWidth / 2, y, width: capWidth, height: h, rx: capWidth / 2,
|
|
67
|
+
}));
|
|
68
|
+
nodes.push(el('text', {
|
|
69
|
+
class: 'cap-label', x: cx, y: y + capWidth / 2 + 4, 'text-anchor': 'middle',
|
|
70
|
+
}, [txt(item.value)]));
|
|
71
|
+
nodes.push(el('text', {
|
|
72
|
+
x: cx, y: height - 10, 'text-anchor': 'middle',
|
|
73
|
+
}, [txt(item.label.length > 9 ? `${item.label.slice(0, 8)}…` : item.label)]));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return frame(width, height, label, nodes);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Horizontal capsules on a shared axis — the reference's timeline.
|
|
81
|
+
*
|
|
82
|
+
* Used for sessions: where each one sat in the day and how long it ran, which is
|
|
83
|
+
* the one view that makes a four-hour session with eleven minutes of work
|
|
84
|
+
* obvious at a glance.
|
|
85
|
+
*
|
|
86
|
+
* @param {{row: string, start: number, end: number, value: string, tone: string}[]} bars
|
|
87
|
+
*/
|
|
88
|
+
export function timeline(bars, { min, max, ticks = [], height = null, label = 'timeline' } = {}) {
|
|
89
|
+
const width = 640;
|
|
90
|
+
const left = 46;
|
|
91
|
+
const rowH = 38;
|
|
92
|
+
const total = height ?? bars.length * rowH + 34;
|
|
93
|
+
const span = Math.max(max - min, 1);
|
|
94
|
+
const x = (value) => left + ((value - min) / span) * (width - left - 10);
|
|
95
|
+
|
|
96
|
+
const nodes = [];
|
|
97
|
+
|
|
98
|
+
for (const tick of ticks) {
|
|
99
|
+
nodes.push(el('line', { class: 'grid-line', x1: x(tick.at), y1: 4, x2: x(tick.at), y2: total - 22 }));
|
|
100
|
+
nodes.push(el('text', { x: x(tick.at), y: total - 6, 'text-anchor': 'middle' }, [txt(tick.label)]));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
bars.forEach((bar, index) => {
|
|
104
|
+
const y = index * rowH + 6;
|
|
105
|
+
nodes.push(el('text', { x: 0, y: y + 14, 'dominant-baseline': 'middle' }, [txt(bar.row)]));
|
|
106
|
+
|
|
107
|
+
// An empty day is a short neutral stub with the value beside it. Drawing a
|
|
108
|
+
// capsule big enough to hold "0m" would make nothing look like something.
|
|
109
|
+
if (!bar.end || bar.end <= bar.start) {
|
|
110
|
+
nodes.push(el('rect', { x: x(bar.start), y: y + 5, width: 18, height: 18, rx: 9, fill: 'var(--track)' }));
|
|
111
|
+
nodes.push(el('text', { x: x(bar.start) + 26, y: y + 14, 'dominant-baseline': 'middle' }, [txt(bar.empty ?? '—')]));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const x0 = x(bar.start);
|
|
116
|
+
const x1 = Math.max(x0 + 34, x(bar.end));
|
|
117
|
+
nodes.push(el('rect', {
|
|
118
|
+
class: TONE[bar.tone] ?? TONE.white,
|
|
119
|
+
x: x0, y, width: x1 - x0, height: 28, rx: 14,
|
|
120
|
+
}));
|
|
121
|
+
nodes.push(el('text', {
|
|
122
|
+
class: 'cap-label', x: x1 - 12, y: y + 15, 'text-anchor': 'end', 'dominant-baseline': 'middle',
|
|
123
|
+
}, [txt(bar.value)]));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return frame(width, total, label, nodes);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Two smooth series, no axes — the reference's small line card.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately unlabelled on the y axis: this shows shape, not magnitude. The
|
|
133
|
+
* numbers that matter are printed above it as figures.
|
|
134
|
+
*/
|
|
135
|
+
export function dualLine(seriesA, seriesB, { height = 96, label = 'trend' } = {}) {
|
|
136
|
+
const width = 320;
|
|
137
|
+
const pad = 8;
|
|
138
|
+
const all = [...seriesA, ...seriesB];
|
|
139
|
+
const max = Math.max(...all, 1);
|
|
140
|
+
const min = Math.min(...all, 0);
|
|
141
|
+
const span = Math.max(max - min, 1);
|
|
142
|
+
|
|
143
|
+
const path = (values) => {
|
|
144
|
+
if (values.length < 2) return '';
|
|
145
|
+
const step = (width - pad * 2) / (values.length - 1);
|
|
146
|
+
const points = values.map((value, index) => [
|
|
147
|
+
pad + index * step,
|
|
148
|
+
height - pad - ((value - min) / span) * (height - pad * 2),
|
|
149
|
+
]);
|
|
150
|
+
// Catmull-Rom to cubic: the reference's lines are smooth, and a polyline
|
|
151
|
+
// through daily counts reads as noise where a curve reads as a trend.
|
|
152
|
+
let d = `M ${points[0][0]} ${points[0][1]}`;
|
|
153
|
+
for (let i = 0; i < points.length - 1; i += 1) {
|
|
154
|
+
const p0 = points[i - 1] ?? points[i];
|
|
155
|
+
const p1 = points[i];
|
|
156
|
+
const p2 = points[i + 1];
|
|
157
|
+
const p3 = points[i + 2] ?? p2;
|
|
158
|
+
d += ` C ${p1[0] + (p2[0] - p0[0]) / 6} ${p1[1] + (p2[1] - p0[1]) / 6},` +
|
|
159
|
+
` ${p2[0] - (p3[0] - p1[0]) / 6} ${p2[1] - (p3[1] - p1[1]) / 6},` +
|
|
160
|
+
` ${p2[0]} ${p2[1]}`;
|
|
161
|
+
}
|
|
162
|
+
return d;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return frame(width, height, label, [
|
|
166
|
+
seriesB.length > 1 ? el('path', { class: 'series-orange', d: path(seriesB) }) : null,
|
|
167
|
+
seriesA.length > 1 ? el('path', { class: 'series-lime', d: path(seriesA) }) : null,
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A row of day capsules. Compact activity: one capsule per day, height by
|
|
173
|
+
* volume, so a gap in the week is visible as an empty slot rather than absent.
|
|
174
|
+
*/
|
|
175
|
+
export function dayStrip(days, { height = 74, label = 'activity by day' } = {}) {
|
|
176
|
+
const width = 640;
|
|
177
|
+
const max = Math.max(...days.map((day) => day.value), 1);
|
|
178
|
+
const slot = width / Math.max(days.length, 1);
|
|
179
|
+
const capWidth = Math.min(30, slot * 0.55);
|
|
180
|
+
const floor = height - 20;
|
|
181
|
+
|
|
182
|
+
const nodes = [];
|
|
183
|
+
days.forEach((day, index) => {
|
|
184
|
+
const cx = index * slot + slot / 2;
|
|
185
|
+
const h = day.value === 0 ? capWidth * 0.5 : Math.max(capWidth, (day.value / max) * (floor - 8));
|
|
186
|
+
nodes.push(el('rect', {
|
|
187
|
+
class: day.value === 0 ? '' : TONE[day.tone] ?? TONE.lime,
|
|
188
|
+
fill: day.value === 0 ? 'var(--track)' : null,
|
|
189
|
+
x: cx - capWidth / 2, y: floor - h, width: capWidth, height: h, rx: capWidth / 2,
|
|
190
|
+
}));
|
|
191
|
+
if (index === 0 || index === days.length - 1 || days.length <= 8) {
|
|
192
|
+
nodes.push(el('text', { x: cx, y: height - 4, 'text-anchor': 'middle' }, [txt(day.label)]));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return frame(width, height, label, nodes);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A ranked list of horizontal capsule meters. For anything where the label
|
|
200
|
+
* matters as much as the value: top files, commands, permission offenders.
|
|
201
|
+
*/
|
|
202
|
+
export function ranked(items, { formatValue = String, max = null } = {}) {
|
|
203
|
+
const ceiling = max ?? Math.max(...items.map((item) => item.value), 1);
|
|
204
|
+
const wrap = document.createElement('div');
|
|
205
|
+
wrap.style.cssText = 'display:grid;gap:10px';
|
|
206
|
+
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
const row = document.createElement('div');
|
|
209
|
+
row.style.cssText = 'display:grid;grid-template-columns:minmax(0,118px) minmax(0,1fr) auto;gap:14px;align-items:center';
|
|
210
|
+
|
|
211
|
+
const name = document.createElement('span');
|
|
212
|
+
name.style.cssText = 'font-size:12px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
|
|
213
|
+
name.title = item.label;
|
|
214
|
+
name.textContent = item.label;
|
|
215
|
+
|
|
216
|
+
const meter = document.createElement('div');
|
|
217
|
+
meter.className = 'meter';
|
|
218
|
+
const fill = document.createElement('div');
|
|
219
|
+
fill.className = `meter__fill${item.tone === 'orange' ? ' meter__fill--orange' : item.tone === 'white' ? ' meter__fill--white' : ''}`;
|
|
220
|
+
fill.style.width = `${Math.max(3, (item.value / ceiling) * 100)}%`;
|
|
221
|
+
meter.appendChild(fill);
|
|
222
|
+
|
|
223
|
+
const value = document.createElement('span');
|
|
224
|
+
value.className = 'tnum';
|
|
225
|
+
value.style.cssText = 'font-size:12px;font-weight:600';
|
|
226
|
+
value.textContent = formatValue(item.value);
|
|
227
|
+
|
|
228
|
+
row.append(name, meter, value);
|
|
229
|
+
wrap.appendChild(row);
|
|
230
|
+
}
|
|
231
|
+
return wrap;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The score dial: a 270° capsule arc.
|
|
236
|
+
*
|
|
237
|
+
* Its colour is the judgement — lime above 70, orange below 45, white between —
|
|
238
|
+
* so the ring answers "is this good" before the number is even read.
|
|
239
|
+
*/
|
|
240
|
+
export function dial(score, { size = 168, label = 'efficiency score' } = {}) {
|
|
241
|
+
const stroke = 14;
|
|
242
|
+
const radius = (size - stroke) / 2;
|
|
243
|
+
const c = size / 2;
|
|
244
|
+
const START = 135;
|
|
245
|
+
const SWEEP = 270;
|
|
246
|
+
|
|
247
|
+
const arc = (from, extent) => {
|
|
248
|
+
const a0 = (from * Math.PI) / 180;
|
|
249
|
+
const a1 = ((from + extent) * Math.PI) / 180;
|
|
250
|
+
return `M ${c + radius * Math.cos(a0)} ${c + radius * Math.sin(a0)} ` +
|
|
251
|
+
`A ${radius} ${radius} 0 ${extent > 180 ? 1 : 0} 1 ` +
|
|
252
|
+
`${c + radius * Math.cos(a1)} ${c + radius * Math.sin(a1)}`;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const tone = score === null ? 'var(--ink-3)' : score >= 70 ? 'var(--lime)' : score < 45 ? 'var(--orange)' : 'var(--neutral)';
|
|
256
|
+
|
|
257
|
+
const node = el('svg', {
|
|
258
|
+
class: 'chart', viewBox: `0 0 ${size} ${size}`, role: 'img',
|
|
259
|
+
'aria-label': `${label}: ${score === null ? 'not enough data' : `${score} out of 100`}`,
|
|
260
|
+
}, [
|
|
261
|
+
el('path', { d: arc(START, SWEEP), fill: 'none', stroke: 'var(--track)', 'stroke-width': stroke, 'stroke-linecap': 'round' }),
|
|
262
|
+
score === null ? null : el('path', {
|
|
263
|
+
d: arc(START, Math.max(2, (score / 100) * SWEEP)),
|
|
264
|
+
fill: 'none', stroke: tone, 'stroke-width': stroke, 'stroke-linecap': 'round',
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
node.style.width = `${size}px`;
|
|
268
|
+
node.style.height = `${size}px`;
|
|
269
|
+
node.style.flex = 'none';
|
|
270
|
+
return node;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export { TONE };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>SynDes</title>
|
|
8
|
+
<link rel="icon" type="image/png" href="/logo.png">
|
|
9
|
+
<link rel="stylesheet" href="/app.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<!--
|
|
13
|
+
One entry point. app.js renders either the login screen or the app shell into
|
|
14
|
+
#root, so there is no redirect between two documents and no second copy of the
|
|
15
|
+
header markup to keep in sync.
|
|
16
|
+
|
|
17
|
+
No inline script: the server's CSP is script-src 'self', which is what stops a
|
|
18
|
+
bug in any view from becoming a way to run injected markup.
|
|
19
|
+
-->
|
|
20
|
+
<div id="root"></div>
|
|
21
|
+
<script type="module" src="/app.js"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
Binary file
|