@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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createContext, useState, useRef, useEffect, useId, useContext } from 'react';
2
- import { NavLink } from 'react-router-dom';
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.2.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-dom": "^6 || ^7",
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-dom": "^7.1.1",
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
+ }