affora 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.
@@ -0,0 +1,454 @@
1
+ import type React from "react"
2
+ import { useId, useMemo, useState } from "react"
3
+
4
+ /* DataTable — a dense orders data table.
5
+ *
6
+ * Agent-legible substrate (never compromised):
7
+ * - real <table> with <caption>, <thead>/<th scope="col">, <tbody>/<td>
8
+ * - sortable columns are real <button>s inside the <th>, toggling aria-sort
9
+ * (none -> ascending -> descending) with a visible caret
10
+ * - every task-relevant value lives as text in the DOM (Name, Role, Status,
11
+ * Last active, Commits); the status pill's meaning is real text, not colour-only
12
+ * - each row's "view" control is a real <button>; clicking it calls onCommit(name)
13
+ *
14
+ * Style-neutral: every visual property is a var(--token). Authored once, rendered
15
+ * under all five themes; the look is entirely carried by the theme wrapper.
16
+ */
17
+
18
+ type SortKey = "name" | "role" | "status" | "active" | "commits"
19
+ type Dir = "asc" | "desc"
20
+
21
+ type Member = {
22
+ name: string
23
+ role: string
24
+ status: "active" | "away" | "offline"
25
+ /** minutes since last active — sortable key; `activeLabel` is what's displayed */
26
+ activeMins: number
27
+ activeLabel: string
28
+ commits: number
29
+ }
30
+
31
+ const ROWS: Member[] = [
32
+ { name: "Order #1001", role: "TRK-12", status: "active", activeMins: 2, activeLabel: "2 min ago", commits: 1284 },
33
+ { name: "Order #1002", role: "TRK-88", status: "active", activeMins: 11, activeLabel: "11 min ago", commits: 642 },
34
+ { name: "Order #1003", role: "TRK-31", status: "away", activeMins: 47, activeLabel: "47 min ago", commits: 98 },
35
+ { name: "Order #1004", role: "TRK-57", status: "active", activeMins: 5, activeLabel: "5 min ago", commits: 917 },
36
+ { name: "Order #1005", role: "TRK-09", status: "away", activeMins: 133, activeLabel: "2 hr ago", commits: 405 },
37
+ { name: "Order #1006", role: "TRK-44", status: "offline", activeMins: 1440, activeLabel: "Yesterday", commits: 1553 },
38
+ { name: "Order #1007", role: "TRK-73", status: "offline", activeMins: 4320, activeLabel: "3 days ago", commits: 271 },
39
+ ]
40
+
41
+ const STATUS_LABEL: Record<Member["status"], string> = {
42
+ active: "Active",
43
+ away: "Away",
44
+ offline: "Offline",
45
+ }
46
+
47
+ // Rank used only for sorting the Status column (active first).
48
+ const STATUS_RANK: Record<Member["status"], number> = { active: 0, away: 1, offline: 2 }
49
+
50
+ const COLS: { key: SortKey; label: string; sortable: boolean; numeric?: boolean }[] = [
51
+ { key: "name", label: "Order", sortable: true },
52
+ { key: "role", label: "Tracking", sortable: true },
53
+ { key: "status", label: "Status", sortable: true },
54
+ { key: "active", label: "Last active", sortable: true },
55
+ { key: "commits", label: "Total", sortable: true, numeric: true },
56
+ ]
57
+
58
+ const Caret: React.FC<{ dir: Dir | null }> = ({ dir }) => (
59
+ <svg
60
+ aria-hidden="true"
61
+ viewBox="0 0 16 16"
62
+ width="1em"
63
+ height="1em"
64
+ style={{
65
+ flex: "0 0 auto",
66
+ opacity: dir ? 1 : 0.28,
67
+ transform: dir === "desc" ? "rotate(180deg)" : "rotate(0deg)",
68
+ transition: "transform var(--dur) var(--ease), opacity var(--dur-fast) var(--ease)",
69
+ }}
70
+ >
71
+ {/* An up-chevron; rotates 180° for descending. Muted when column is unsorted. */}
72
+ <path
73
+ d="M4 10.5 L8 6 L12 10.5"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth="1.75"
77
+ strokeLinecap="round"
78
+ strokeLinejoin="round"
79
+ />
80
+ </svg>
81
+ )
82
+
83
+ const StatusPill: React.FC<{ status: Member["status"] }> = ({ status }) => {
84
+ const tone =
85
+ status === "active" ? "var(--success)" : status === "away" ? "var(--warning)" : "var(--fg-subtle)"
86
+ return (
87
+ <span
88
+ className="dtf-pill"
89
+ style={{
90
+ // colour-mix keeps the pill fill tied to the semantic token in every theme
91
+ // while staying legible; the text label carries the meaning for agents.
92
+ color: tone,
93
+ borderColor: `color-mix(in srgb, ${tone} 34%, transparent)`,
94
+ background: `color-mix(in srgb, ${tone} 12%, transparent)`,
95
+ }}
96
+ >
97
+ <span
98
+ aria-hidden="true"
99
+ style={{
100
+ width: "0.5em",
101
+ height: "0.5em",
102
+ borderRadius: "var(--radius-full)",
103
+ background: tone,
104
+ flex: "0 0 auto",
105
+ }}
106
+ />
107
+ {STATUS_LABEL[status]}
108
+ </span>
109
+ )
110
+ }
111
+
112
+ export const DataTable: React.FC<{ onCommit?: (v: string) => void }> = ({ onCommit }) => {
113
+ const confirmId = useId()
114
+ const [entry, setEntry] = useState("")
115
+ const [sortKey, setSortKey] = useState<SortKey>("commits")
116
+ const [dir, setDir] = useState<Dir>("desc")
117
+
118
+ const rows = useMemo(() => {
119
+ const factor = dir === "asc" ? 1 : -1
120
+ const val = (m: Member): number | string => {
121
+ switch (sortKey) {
122
+ case "name": return m.name.toLowerCase()
123
+ case "role": return m.role.toLowerCase()
124
+ case "status": return STATUS_RANK[m.status]
125
+ case "active": return m.activeMins
126
+ case "commits": return m.commits
127
+ }
128
+ }
129
+ return [...ROWS].sort((a, b) => {
130
+ const va = val(a), vb = val(b)
131
+ if (va < vb) return -1 * factor
132
+ if (va > vb) return 1 * factor
133
+ return 0
134
+ })
135
+ }, [sortKey, dir])
136
+
137
+ const onSort = (key: SortKey) => {
138
+ if (key === sortKey) {
139
+ setDir((d) => (d === "asc" ? "desc" : "asc"))
140
+ } else {
141
+ setSortKey(key)
142
+ // numeric columns default to descending (biggest first); text to ascending
143
+ setDir(key === "commits" || key === "active" ? "desc" : "asc")
144
+ }
145
+ }
146
+
147
+ const ariaSort = (key: SortKey): "none" | "ascending" | "descending" =>
148
+ key !== sortKey ? "none" : dir === "asc" ? "ascending" : "descending"
149
+
150
+ const px = (mult: number) => `calc(var(--text-base) * ${mult})`
151
+
152
+ return (
153
+ <div
154
+ className="dtf-root"
155
+ style={{
156
+ fontFamily: "var(--font)",
157
+ fontSize: "var(--text-base)",
158
+ lineHeight: "var(--leading)",
159
+ color: "var(--fg)",
160
+ background: "var(--bg)",
161
+ letterSpacing: "var(--tracking-tight)",
162
+ padding: "var(--pad-loose)",
163
+ }}
164
+ >
165
+ <style>{`
166
+ .dtf-root { -webkit-font-smoothing: antialiased; }
167
+ .dtf-confirm {
168
+ display: flex; align-items: center; gap: var(--pad-tight);
169
+ padding: var(--pad) var(--pad-loose);
170
+ border-top: 1px solid var(--border);
171
+ }
172
+ .dtf-confirm-label { color: var(--fg-muted); }
173
+ .dtf-confirm-input {
174
+ flex: 1; min-width: 0;
175
+ padding: var(--pad-tight) var(--pad);
176
+ font: inherit; color: var(--fg);
177
+ background: var(--surface-2);
178
+ border: 1px solid var(--border-strong);
179
+ border-radius: var(--radius);
180
+ }
181
+ .dtf-confirm-input:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
182
+ .dtf-confirm-btn {
183
+ padding: var(--pad-tight) var(--pad);
184
+ font: inherit; font-weight: var(--weight-medium);
185
+ color: var(--accent-fg); background: var(--accent);
186
+ border: 1px solid var(--accent); border-radius: var(--radius);
187
+ cursor: pointer;
188
+ }
189
+ [data-layout] .dtf-shell { max-width: var(--lay-width, none); }
190
+ [data-layout] .dtf-cell, [data-layout] .dtf-th {
191
+ padding-block: calc(var(--pad-tight) * var(--lay-density, 1));
192
+ }
193
+ .dtf-shell {
194
+ background: var(--glass-bg, var(--surface));
195
+ backdrop-filter: var(--glass-blur, none);
196
+ -webkit-backdrop-filter: var(--glass-blur, none);
197
+ border: 1px solid var(--glass-border, var(--border));
198
+ border-radius: var(--radius-lg);
199
+ box-shadow: var(--shadow-sm);
200
+ overflow: hidden;
201
+ }
202
+ .dtf-head {
203
+ display: flex; align-items: baseline; justify-content: space-between;
204
+ gap: var(--gap);
205
+ padding: var(--pad) var(--pad-loose);
206
+ border-bottom: 1px solid var(--border);
207
+ background: var(--surface);
208
+ }
209
+ .dtf-scroll { overflow-x: auto; }
210
+ .dtf-table {
211
+ width: 100%;
212
+ border-collapse: collapse;
213
+ text-align: left;
214
+ font-variant-numeric: tabular-nums;
215
+ }
216
+ .dtf-table caption {
217
+ text-align: left;
218
+ padding: 0;
219
+ margin: 0;
220
+ caption-side: top;
221
+ }
222
+ .dtf-th {
223
+ position: sticky; top: 0;
224
+ background: var(--surface-2);
225
+ color: var(--fg-muted);
226
+ font-family: var(--font-display);
227
+ font-weight: var(--weight-medium);
228
+ text-transform: uppercase;
229
+ letter-spacing: 0.06em;
230
+ white-space: nowrap;
231
+ border-bottom: 1px solid var(--border-strong);
232
+ padding: 0;
233
+ }
234
+ .dtf-th-inner {
235
+ display: block;
236
+ padding: var(--pad-tight) var(--pad);
237
+ }
238
+ .dtf-sortbtn {
239
+ appearance: none;
240
+ -webkit-appearance: none;
241
+ font: inherit;
242
+ text-transform: inherit;
243
+ letter-spacing: inherit;
244
+ color: inherit;
245
+ background: transparent;
246
+ border: 0;
247
+ margin: 0;
248
+ padding: var(--pad-tight) var(--pad);
249
+ width: 100%;
250
+ display: inline-flex;
251
+ align-items: center;
252
+ gap: calc(var(--gap) * 0.5);
253
+ cursor: pointer;
254
+ border-radius: var(--radius-sm);
255
+ transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
256
+ }
257
+ .dtf-sortbtn.num { justify-content: flex-end; }
258
+ .dtf-sortbtn:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 5%, transparent); }
259
+ .dtf-th[aria-sort="ascending"] .dtf-sortbtn,
260
+ .dtf-th[aria-sort="descending"] .dtf-sortbtn { color: var(--fg); }
261
+ .dtf-sortbtn:focus-visible {
262
+ outline: none;
263
+ box-shadow: 0 0 0 2px var(--surface-2), 0 0 0 4px var(--ring);
264
+ }
265
+ .dtf-td {
266
+ padding: var(--pad) var(--pad);
267
+ border-bottom: 1px solid var(--border);
268
+ color: var(--fg);
269
+ vertical-align: middle;
270
+ white-space: nowrap;
271
+ }
272
+ .dtf-row:last-child .dtf-td { border-bottom: 0; }
273
+ .dtf-row:nth-child(even) .dtf-td { background: color-mix(in srgb, var(--fg) 3%, transparent); }
274
+ .dtf-row:hover .dtf-td { background: var(--accent-weak); }
275
+ .dtf-num {
276
+ text-align: right;
277
+ font-family: var(--font-mono);
278
+ font-variant-numeric: tabular-nums;
279
+ font-weight: var(--weight-medium);
280
+ }
281
+ .dtf-pill {
282
+ display: inline-flex; align-items: center; gap: 0.5em;
283
+ padding: 0.2em 0.66em;
284
+ border: 1px solid;
285
+ border-radius: var(--radius-full);
286
+ font-size: 0.86em;
287
+ font-weight: var(--weight-medium);
288
+ line-height: 1.2;
289
+ white-space: nowrap;
290
+ }
291
+ .dtf-view {
292
+ appearance: none; -webkit-appearance: none;
293
+ font: inherit;
294
+ font-family: var(--font);
295
+ font-weight: var(--weight-medium);
296
+ font-size: 0.9em;
297
+ cursor: pointer;
298
+ color: var(--accent);
299
+ background: transparent;
300
+ border: 1px solid var(--border-strong);
301
+ border-radius: var(--radius-sm);
302
+ padding: calc(var(--pad-tight) * 0.75) var(--pad);
303
+ display: inline-flex; align-items: center; gap: 0.4em;
304
+ transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
305
+ }
306
+ .dtf-view:hover { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
307
+ .dtf-view:active { transform: translateY(0.5px); }
308
+ .dtf-view:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--ring); }
309
+ `}</style>
310
+
311
+ <div className="dtf-shell">
312
+ <div className="dtf-head">
313
+ <div>
314
+ <div
315
+ style={{
316
+ fontFamily: "var(--font-display)",
317
+ fontWeight: "var(--weight-display)",
318
+ fontSize: px(1.35),
319
+ lineHeight: 1.2,
320
+ color: "var(--fg)",
321
+ }}
322
+ >
323
+ Orders
324
+ </div>
325
+ <div style={{ color: "var(--fg-muted)", fontSize: px(0.9), marginTop: "calc(var(--gap) * 0.25)" }}>
326
+ {ROWS.length} people · sorted by {COLS.find((c) => c.key === sortKey)?.label.toLowerCase()}
327
+ </div>
328
+ </div>
329
+ <span
330
+ style={{
331
+ fontFamily: "var(--font-mono)",
332
+ fontSize: px(0.82),
333
+ color: "var(--fg-subtle)",
334
+ fontVariantNumeric: "tabular-nums",
335
+ whiteSpace: "nowrap",
336
+ }}
337
+ >
338
+ {ROWS.filter((r) => r.status === "active").length} active now
339
+ </span>
340
+ </div>
341
+
342
+ <div className="dtf-scroll">
343
+ <table className="dtf-table">
344
+ <caption
345
+ style={{
346
+ padding: "var(--pad-tight) var(--pad-loose)",
347
+ color: "var(--fg-subtle)",
348
+ fontSize: px(0.82),
349
+ borderBottom: "1px solid var(--border)",
350
+ }}
351
+ >
352
+ Orders with role, presence, last-active time and lifetime commit count. Column headers sort the table.
353
+ </caption>
354
+ <thead>
355
+ <tr>
356
+ {COLS.map((c) => (
357
+ <th
358
+ key={c.key}
359
+ scope="col"
360
+ className="dtf-th"
361
+ aria-sort={c.sortable ? ariaSort(c.key) : undefined}
362
+ style={c.numeric ? { textAlign: "right" } : undefined}
363
+ >
364
+ {c.sortable ? (
365
+ <button
366
+ type="button"
367
+ className={"dtf-sortbtn" + (c.numeric ? " num" : "")}
368
+ onClick={() => onSort(c.key)}
369
+ aria-label={`Sort by ${c.label}${
370
+ ariaSort(c.key) === "none"
371
+ ? ""
372
+ : ariaSort(c.key) === "ascending"
373
+ ? ", currently ascending"
374
+ : ", currently descending"
375
+ }`}
376
+ >
377
+ <span>{c.label}</span>
378
+ <Caret dir={sortKey === c.key ? dir : null} />
379
+ </button>
380
+ ) : (
381
+ <span className="dtf-th-inner">{c.label}</span>
382
+ )}
383
+ </th>
384
+ ))}
385
+ <th scope="col" className="dtf-th" style={{ textAlign: "right" }}>
386
+ <span className="dtf-th-inner" style={{ textAlign: "right" }}>
387
+ <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
388
+ Actions
389
+ </span>
390
+ <span aria-hidden="true">&nbsp;</span>
391
+ </span>
392
+ </th>
393
+ </tr>
394
+ </thead>
395
+ <tbody>
396
+ {rows.map((m) => (
397
+ <tr key={m.name} className="dtf-row">
398
+ <td className="dtf-td">
399
+ <span style={{ fontWeight: "var(--weight-bold)", color: "var(--fg)" }}>{m.name}</span>
400
+ </td>
401
+ <td className="dtf-td" style={{ color: "var(--fg-muted)" }}>
402
+ {m.role}
403
+ </td>
404
+ <td className="dtf-td">
405
+ <StatusPill status={m.status} />
406
+ </td>
407
+ <td className="dtf-td" style={{ color: "var(--fg-muted)" }}>
408
+ {m.activeLabel}
409
+ </td>
410
+ <td className="dtf-td dtf-num">{m.commits.toLocaleString("en-US")}</td>
411
+ <td className="dtf-td" style={{ textAlign: "right" }}>
412
+ <button
413
+ type="button"
414
+ className="dtf-view"
415
+ onClick={() => setEntry(m.role)}
416
+ aria-label={`Use ${m.role} from ${m.name} — fills the tracking field`}
417
+ >
418
+ <svg aria-hidden="true" viewBox="0 0 16 16" width="1em" height="1em" style={{ flex: "0 0 auto" }}>
419
+ <path
420
+ d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
421
+ fill="none"
422
+ stroke="currentColor"
423
+ strokeWidth="1.25"
424
+ strokeLinejoin="round"
425
+ />
426
+ <circle cx="8" cy="8" r="1.9" fill="none" stroke="currentColor" strokeWidth="1.25" />
427
+ </svg>
428
+ Use
429
+ </button>
430
+ </td>
431
+ </tr>
432
+ ))}
433
+ </tbody>
434
+ </table>
435
+ <div className="dtf-confirm">
436
+ <label className="dtf-confirm-label" htmlFor={confirmId}>Tracking number</label>
437
+ <input
438
+ id={confirmId}
439
+ className="dtf-confirm-input"
440
+ value={entry}
441
+ placeholder="e.g. TRK-00"
442
+ onChange={(e) => setEntry(e.target.value)}
443
+ />
444
+ <button type="button" className="dtf-confirm-btn" onClick={() => onCommit?.(entry.trim())}>
445
+ Submit
446
+ </button>
447
+ </div>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ )
452
+ }
453
+
454
+ export default DataTable