@fasl-work/caos-app-shell 0.2.0 → 0.5.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 +201 -0
- package/README.md +76 -56
- package/dist/index.d.ts +174 -1
- package/dist/index.js +339 -3
- package/package.json +12 -7
- package/styles.css +300 -218
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createContext, useState, useRef, useEffect, useId, useContext } from 'react';
|
|
2
|
-
import { NavLink } from 'react-router
|
|
1
|
+
import { createContext, useState, useRef, useEffect, useCallback, useId, useContext } from 'react';
|
|
2
|
+
import { NavLink } from 'react-router';
|
|
3
3
|
import { Sun, Moon, Languages, Boxes, Github, Globe, Briefcase, Info } from 'lucide-react';
|
|
4
4
|
import { create } from 'zustand';
|
|
5
5
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
@@ -308,6 +308,342 @@ function AppShell({ config, children }) {
|
|
|
308
308
|
] }) }) })
|
|
309
309
|
] });
|
|
310
310
|
}
|
|
311
|
+
function WorkbenchShell({
|
|
312
|
+
brand,
|
|
313
|
+
routes,
|
|
314
|
+
navigationLabel,
|
|
315
|
+
children,
|
|
316
|
+
sidebarFooter,
|
|
317
|
+
headerLead,
|
|
318
|
+
headerActions,
|
|
319
|
+
overlays,
|
|
320
|
+
mainId = "main-content",
|
|
321
|
+
className
|
|
322
|
+
}) {
|
|
323
|
+
const rootClassName = ["caos-workbench-shell", "app-shell", className].filter(Boolean).join(" ");
|
|
324
|
+
return /* @__PURE__ */ jsxs("div", { className: rootClassName, children: [
|
|
325
|
+
/* @__PURE__ */ jsxs(
|
|
326
|
+
"aside",
|
|
327
|
+
{
|
|
328
|
+
className: "caos-workbench-sidebar sidebar",
|
|
329
|
+
style: { "--caos-workbench-route-count": routes.length },
|
|
330
|
+
children: [
|
|
331
|
+
brand,
|
|
332
|
+
/* @__PURE__ */ jsx("nav", { "aria-label": navigationLabel, children: routes.map((route) => /* @__PURE__ */ jsxs(
|
|
333
|
+
NavLink,
|
|
334
|
+
{
|
|
335
|
+
to: route.path,
|
|
336
|
+
end: route.end ?? route.path === "/",
|
|
337
|
+
"aria-label": route.ariaLabel,
|
|
338
|
+
className: ({ isActive }) => isActive ? "active" : void 0,
|
|
339
|
+
children: [
|
|
340
|
+
route.icon,
|
|
341
|
+
/* @__PURE__ */ jsx("span", { children: route.label })
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
route.path
|
|
345
|
+
)) }),
|
|
346
|
+
sidebarFooter
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
),
|
|
350
|
+
/* @__PURE__ */ jsxs("div", { className: "caos-workbench-main shell-main", children: [
|
|
351
|
+
/* @__PURE__ */ jsxs("header", { className: "caos-workbench-header topbar", children: [
|
|
352
|
+
headerLead,
|
|
353
|
+
headerActions
|
|
354
|
+
] }),
|
|
355
|
+
/* @__PURE__ */ jsx("main", { id: mainId, className: "caos-workbench-content content", children })
|
|
356
|
+
] }),
|
|
357
|
+
overlays
|
|
358
|
+
] });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/lib/vizLoop.ts
|
|
362
|
+
function createVizLoop(frame, options, deps) {
|
|
363
|
+
const { loop = false, durationMs, onComplete, onPlayingChange } = options;
|
|
364
|
+
const { raf, caf } = deps;
|
|
365
|
+
let handle = null;
|
|
366
|
+
let want = false;
|
|
367
|
+
let hidden = false;
|
|
368
|
+
let last = 0;
|
|
369
|
+
let baseline = true;
|
|
370
|
+
let elapsed = 0;
|
|
371
|
+
const playing = () => handle !== null;
|
|
372
|
+
const emitPlaying = (was) => {
|
|
373
|
+
if (was !== playing()) onPlayingChange?.(playing());
|
|
374
|
+
};
|
|
375
|
+
const request = () => {
|
|
376
|
+
if (handle === null) handle = raf(tick);
|
|
377
|
+
};
|
|
378
|
+
const cancel = () => {
|
|
379
|
+
if (handle !== null) {
|
|
380
|
+
caf(handle);
|
|
381
|
+
handle = null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
function tick(t) {
|
|
385
|
+
handle = null;
|
|
386
|
+
if (baseline) {
|
|
387
|
+
last = t;
|
|
388
|
+
baseline = false;
|
|
389
|
+
}
|
|
390
|
+
const dt = t - last;
|
|
391
|
+
last = t;
|
|
392
|
+
elapsed += dt;
|
|
393
|
+
let e = elapsed;
|
|
394
|
+
const capped = durationMs != null && e >= durationMs;
|
|
395
|
+
if (capped) e = durationMs;
|
|
396
|
+
const cont = frame(dt, e);
|
|
397
|
+
if (capped || cont === false) {
|
|
398
|
+
if (loop) {
|
|
399
|
+
elapsed = 0;
|
|
400
|
+
baseline = true;
|
|
401
|
+
request();
|
|
402
|
+
} else {
|
|
403
|
+
const was = true;
|
|
404
|
+
want = false;
|
|
405
|
+
emitPlaying(was);
|
|
406
|
+
onComplete?.();
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
request();
|
|
411
|
+
}
|
|
412
|
+
const play = () => {
|
|
413
|
+
if (want && playing()) return;
|
|
414
|
+
const was = playing();
|
|
415
|
+
want = true;
|
|
416
|
+
baseline = true;
|
|
417
|
+
if (!hidden) request();
|
|
418
|
+
emitPlaying(was);
|
|
419
|
+
};
|
|
420
|
+
const pause = () => {
|
|
421
|
+
const was = playing();
|
|
422
|
+
want = false;
|
|
423
|
+
cancel();
|
|
424
|
+
emitPlaying(was);
|
|
425
|
+
};
|
|
426
|
+
const restart = () => {
|
|
427
|
+
const was = playing();
|
|
428
|
+
want = true;
|
|
429
|
+
elapsed = 0;
|
|
430
|
+
baseline = true;
|
|
431
|
+
cancel();
|
|
432
|
+
if (!hidden) request();
|
|
433
|
+
emitPlaying(was);
|
|
434
|
+
};
|
|
435
|
+
const setHidden = (h) => {
|
|
436
|
+
if (h === hidden) return;
|
|
437
|
+
hidden = h;
|
|
438
|
+
const was = playing();
|
|
439
|
+
if (hidden) {
|
|
440
|
+
cancel();
|
|
441
|
+
} else if (want) {
|
|
442
|
+
baseline = true;
|
|
443
|
+
request();
|
|
444
|
+
}
|
|
445
|
+
emitPlaying(was);
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
isPlaying: playing,
|
|
449
|
+
isRunning: () => want,
|
|
450
|
+
play,
|
|
451
|
+
pause,
|
|
452
|
+
toggle: () => want ? pause() : play(),
|
|
453
|
+
restart,
|
|
454
|
+
setHidden,
|
|
455
|
+
dispose: () => {
|
|
456
|
+
want = false;
|
|
457
|
+
cancel();
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/lib/usePausedViz.ts
|
|
463
|
+
function usePausedViz(frame, options = {}) {
|
|
464
|
+
const { autoStart = false, loop, durationMs, onComplete } = options;
|
|
465
|
+
const [playing, setPlaying] = useState(false);
|
|
466
|
+
const frameRef = useRef(frame);
|
|
467
|
+
frameRef.current = frame;
|
|
468
|
+
const completeRef = useRef(onComplete);
|
|
469
|
+
completeRef.current = onComplete;
|
|
470
|
+
const loopRef = useRef(null);
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
if (typeof window === "undefined" || typeof requestAnimationFrame === "undefined") return;
|
|
473
|
+
const vloop = createVizLoop(
|
|
474
|
+
(dt, elapsed) => frameRef.current(dt, elapsed),
|
|
475
|
+
{
|
|
476
|
+
loop,
|
|
477
|
+
durationMs,
|
|
478
|
+
onComplete: () => completeRef.current?.(),
|
|
479
|
+
onPlayingChange: setPlaying
|
|
480
|
+
},
|
|
481
|
+
{ raf: requestAnimationFrame, caf: cancelAnimationFrame }
|
|
482
|
+
);
|
|
483
|
+
loopRef.current = vloop;
|
|
484
|
+
const onVisibility = () => vloop.setHidden(document.hidden);
|
|
485
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
486
|
+
if (autoStart && !document.hidden) vloop.play();
|
|
487
|
+
return () => {
|
|
488
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
489
|
+
vloop.dispose();
|
|
490
|
+
loopRef.current = null;
|
|
491
|
+
};
|
|
492
|
+
}, []);
|
|
493
|
+
const play = useCallback(() => loopRef.current?.play(), []);
|
|
494
|
+
const pause = useCallback(() => loopRef.current?.pause(), []);
|
|
495
|
+
const toggle = useCallback(() => loopRef.current?.toggle(), []);
|
|
496
|
+
const restart = useCallback(() => loopRef.current?.restart(), []);
|
|
497
|
+
return { playing, play, pause, toggle, restart };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/case/caseModel.ts
|
|
501
|
+
var caseKind = (c) => c.kind ?? "synthetic";
|
|
502
|
+
var KIND_ORDER = ["synthetic", "real", "uploaded"];
|
|
503
|
+
function sourcesPresent(cases) {
|
|
504
|
+
const set = new Set(cases.map(caseKind));
|
|
505
|
+
return KIND_ORDER.filter((k) => set.has(k));
|
|
506
|
+
}
|
|
507
|
+
function groupByCategory(cases) {
|
|
508
|
+
const order = [];
|
|
509
|
+
const map = /* @__PURE__ */ new Map();
|
|
510
|
+
for (const c of cases) {
|
|
511
|
+
const cat = c.category ?? "";
|
|
512
|
+
if (!map.has(cat)) {
|
|
513
|
+
map.set(cat, []);
|
|
514
|
+
order.push(cat);
|
|
515
|
+
}
|
|
516
|
+
map.get(cat).push(c);
|
|
517
|
+
}
|
|
518
|
+
return order.map((category) => ({ category, cases: map.get(category) }));
|
|
519
|
+
}
|
|
520
|
+
function casesInSource(cases, source) {
|
|
521
|
+
if (!source) return cases;
|
|
522
|
+
return cases.filter((c) => caseKind(c) === source);
|
|
523
|
+
}
|
|
524
|
+
function caseTooltip(c) {
|
|
525
|
+
return [c.anchor, c.expectedBand].filter(Boolean).join(" \xB7 ");
|
|
526
|
+
}
|
|
527
|
+
function readCaseParam(search, param = "case") {
|
|
528
|
+
return new URLSearchParams(search).get(param);
|
|
529
|
+
}
|
|
530
|
+
function withCaseParam(search, id, param = "case") {
|
|
531
|
+
const p = new URLSearchParams(search);
|
|
532
|
+
p.set(param, id);
|
|
533
|
+
const s = p.toString();
|
|
534
|
+
return s ? `?${s}` : "";
|
|
535
|
+
}
|
|
536
|
+
var TEXT = {
|
|
537
|
+
en: {
|
|
538
|
+
source: "Source",
|
|
539
|
+
synthetic: "Synthetic",
|
|
540
|
+
real: "Real",
|
|
541
|
+
uploaded: "Uploaded",
|
|
542
|
+
lockedNote: "Scenario knobs are locked on a real sample \u2014 you pick which datum; every tool runs on it as-is.",
|
|
543
|
+
modifiedPrefix: "modified from",
|
|
544
|
+
reset: "reset"
|
|
545
|
+
},
|
|
546
|
+
es: {
|
|
547
|
+
source: "Fuente",
|
|
548
|
+
synthetic: "Sint\xE9tico",
|
|
549
|
+
real: "Real",
|
|
550
|
+
uploaded: "Subido",
|
|
551
|
+
lockedNote: "Los controles del escenario quedan bloqueados en una muestra real: eliges el dato; todas las herramientas corren sobre \xE9l tal cual.",
|
|
552
|
+
modifiedPrefix: "modificado desde",
|
|
553
|
+
reset: "restablecer"
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
var KIND_TAG = { synthetic: "S", real: "R", uploaded: "U" };
|
|
557
|
+
function CaseSelector(props) {
|
|
558
|
+
const {
|
|
559
|
+
cases,
|
|
560
|
+
selectedId,
|
|
561
|
+
onSelect,
|
|
562
|
+
source,
|
|
563
|
+
onSourceChange,
|
|
564
|
+
lockedNote,
|
|
565
|
+
modifiedFromId,
|
|
566
|
+
onResetToCanonical,
|
|
567
|
+
lang = "en",
|
|
568
|
+
text,
|
|
569
|
+
deepLink,
|
|
570
|
+
ariaLabel,
|
|
571
|
+
className
|
|
572
|
+
} = props;
|
|
573
|
+
const t = { ...TEXT[lang], ...text };
|
|
574
|
+
const baseId = useId();
|
|
575
|
+
const param = typeof deepLink === "string" ? deepLink : "case";
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
if (!deepLink || typeof window === "undefined") return;
|
|
578
|
+
const fromUrl = readCaseParam(window.location.search, param);
|
|
579
|
+
if (fromUrl && fromUrl !== selectedId && cases.some((c) => c.id === fromUrl)) onSelect(fromUrl);
|
|
580
|
+
}, []);
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
if (!deepLink || typeof window === "undefined") return;
|
|
583
|
+
const next = withCaseParam(window.location.search, selectedId, param);
|
|
584
|
+
if (next !== window.location.search) {
|
|
585
|
+
window.history.replaceState(null, "", `${window.location.pathname}${next}${window.location.hash}`);
|
|
586
|
+
}
|
|
587
|
+
}, [deepLink, param, selectedId]);
|
|
588
|
+
const lanes = source ? sourcesPresent(cases) : [];
|
|
589
|
+
const visible = casesInSource(cases, source);
|
|
590
|
+
const groups = groupByCategory(visible);
|
|
591
|
+
const modified = cases.find((c) => c.id === modifiedFromId) ?? null;
|
|
592
|
+
return /* @__PURE__ */ jsxs("div", { className: `cs${className ? ` ${className}` : ""}`, role: "group", "aria-label": ariaLabel ?? "Case selector", children: [
|
|
593
|
+
source && lanes.length > 1 && /* @__PURE__ */ jsxs("div", { className: "cs-source", children: [
|
|
594
|
+
/* @__PURE__ */ jsx("span", { className: "cs-source-label", children: t.source }),
|
|
595
|
+
/* @__PURE__ */ jsx("div", { className: "cs-source-tabs", role: "tablist", "aria-label": t.source, children: lanes.map((k) => /* @__PURE__ */ jsx(
|
|
596
|
+
"button",
|
|
597
|
+
{
|
|
598
|
+
type: "button",
|
|
599
|
+
role: "tab",
|
|
600
|
+
"aria-selected": source === k,
|
|
601
|
+
className: source === k ? "subtab active" : "subtab",
|
|
602
|
+
onClick: () => onSourceChange?.(k),
|
|
603
|
+
children: k === "synthetic" ? t.synthetic : k === "real" ? t.real : t.uploaded
|
|
604
|
+
},
|
|
605
|
+
k
|
|
606
|
+
)) })
|
|
607
|
+
] }),
|
|
608
|
+
source && source !== "synthetic" && /* @__PURE__ */ jsx("p", { className: "cs-locked banner", children: lockedNote ?? t.lockedNote }),
|
|
609
|
+
groups.map((g) => /* @__PURE__ */ jsxs("div", { className: "cs-group", role: "group", "aria-label": g.category || void 0, children: [
|
|
610
|
+
g.category && /* @__PURE__ */ jsx("span", { className: "cs-group-label", children: g.category }),
|
|
611
|
+
/* @__PURE__ */ jsx("div", { className: "cs-chips", children: g.cases.map((c) => {
|
|
612
|
+
const on = c.id === selectedId;
|
|
613
|
+
const tip = caseTooltip(c);
|
|
614
|
+
return /* @__PURE__ */ jsxs(
|
|
615
|
+
"button",
|
|
616
|
+
{
|
|
617
|
+
type: "button",
|
|
618
|
+
id: `${baseId}-case-${c.id}`,
|
|
619
|
+
className: `cs-chip${on ? " on" : ""}`,
|
|
620
|
+
"aria-pressed": on,
|
|
621
|
+
disabled: c.disabled,
|
|
622
|
+
title: tip || void 0,
|
|
623
|
+
onClick: () => onSelect(c.id),
|
|
624
|
+
children: [
|
|
625
|
+
/* @__PURE__ */ jsx("span", { className: "cs-chip-id", children: c.id }),
|
|
626
|
+
/* @__PURE__ */ jsx("span", { className: "cs-chip-name", children: c.name }),
|
|
627
|
+
/* @__PURE__ */ jsx("span", { className: `cs-kind cs-kind-${caseKindOf(c)}`, "aria-hidden": "true", children: KIND_TAG[caseKindOf(c)] })
|
|
628
|
+
]
|
|
629
|
+
},
|
|
630
|
+
c.id
|
|
631
|
+
);
|
|
632
|
+
}) })
|
|
633
|
+
] }, g.category || "_")),
|
|
634
|
+
modified && /* @__PURE__ */ jsxs("div", { className: "cs-modified", children: [
|
|
635
|
+
/* @__PURE__ */ jsxs("span", { className: "badge accent", children: [
|
|
636
|
+
t.modifiedPrefix,
|
|
637
|
+
" ",
|
|
638
|
+
/* @__PURE__ */ jsx("strong", { children: modified.id })
|
|
639
|
+
] }),
|
|
640
|
+
onResetToCanonical && /* @__PURE__ */ jsx("button", { type: "button", className: "cs-reset", onClick: onResetToCanonical, children: t.reset })
|
|
641
|
+
] })
|
|
642
|
+
] });
|
|
643
|
+
}
|
|
644
|
+
function caseKindOf(c) {
|
|
645
|
+
return c.kind ?? "synthetic";
|
|
646
|
+
}
|
|
311
647
|
function Tabs({ tabs, initial, ariaLabel }) {
|
|
312
648
|
const baseId = useId();
|
|
313
649
|
const first = tabs[0]?.id ?? "";
|
|
@@ -501,4 +837,4 @@ function ReferenceList({ ids, heading }) {
|
|
|
501
837
|
] });
|
|
502
838
|
}
|
|
503
839
|
|
|
504
|
-
export { AppShell, ArchitectureModal, Callout, CitationsProvider, Cite, Equation, Figure, InlineMath, LanguageToggle, ReferenceList, Refs, SubTabs, Tabs, ThemeToggle, applyTheme, readTheme, useLangStore, useShellLang, useThemeStore };
|
|
840
|
+
export { AppShell, ArchitectureModal, Callout, CaseSelector, CitationsProvider, Cite, Equation, Figure, InlineMath, LanguageToggle, ReferenceList, Refs, SubTabs, Tabs, ThemeToggle, WorkbenchShell, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, groupByCategory, readCaseParam, readTheme, sourcesPresent, useLangStore, usePausedViz, useShellLang, useThemeStore, withCaseParam };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fasl-work/caos-app-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Shared web-app shell + content primitives + design system for the CAOS / Faena public apps (ADR-0016).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Felipe Santibáñez-Leal",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsup",
|
|
31
|
+
"test": "node --import tsx --test test/vizLoop.test.ts test/caseModel.test.ts test/workbenchShell.test.tsx",
|
|
31
32
|
"prepublishOnly": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"peerDependencies": {
|
|
@@ -35,10 +36,10 @@
|
|
|
35
36
|
"lucide-react": ">=0.400.0",
|
|
36
37
|
"react": "^18 || ^19",
|
|
37
38
|
"react-dom": "^18 || ^19",
|
|
38
|
-
"react-router
|
|
39
|
+
"react-router": "^6 || ^7 || ^8",
|
|
39
40
|
"zustand": "^4 || ^5"
|
|
40
41
|
},
|
|
41
|
-
"devDependencies": {
|
|
42
|
+
"devDependencies": {
|
|
42
43
|
"@types/katex": "^0.16.7",
|
|
43
44
|
"@types/react": "^19.0.0",
|
|
44
45
|
"@types/react-dom": "^19.0.0",
|
|
@@ -46,9 +47,13 @@
|
|
|
46
47
|
"lucide-react": "^0.469.0",
|
|
47
48
|
"react": "^19.0.0",
|
|
48
49
|
"react-dom": "^19.0.0",
|
|
49
|
-
"react-router
|
|
50
|
+
"react-router": "^8.3.0",
|
|
50
51
|
"tsup": "^8.3.5",
|
|
52
|
+
"tsx": "^4.23.0",
|
|
51
53
|
"typescript": "^5.7.3",
|
|
52
|
-
"zustand": "^5.0.2"
|
|
53
|
-
}
|
|
54
|
-
|
|
54
|
+
"zustand": "^5.0.2"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"esbuild": "0.28.1"
|
|
58
|
+
}
|
|
59
|
+
}
|