@zen-ui-capri/system 0.0.0-stage → 1.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/.agents/skills/capri-zen-ui/SKILL.md +59 -0
- package/AGENTS.md +57 -0
- package/CLAUDE.md +7 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/bin/capri.mjs +1187 -0
- package/dist/lib/audio/ZenAudioController.d.ts +10 -0
- package/dist/lib/audio/ZenAudioController.d.ts.map +1 -0
- package/dist/lib/audio/useZenAudioSynth.d.ts +26 -0
- package/dist/lib/audio/useZenAudioSynth.d.ts.map +1 -0
- package/dist/lib/capri/capri-engine.d.ts +127 -0
- package/dist/lib/capri/capri-engine.d.ts.map +1 -0
- package/dist/lib/components/ZenAIConstruct.d.ts +24 -0
- package/dist/lib/components/ZenAIConstruct.d.ts.map +1 -0
- package/dist/lib/components/ZenAmbientBackground.d.ts +8 -0
- package/dist/lib/components/ZenAmbientBackground.d.ts.map +1 -0
- package/dist/lib/components/ZenBadge.d.ts +21 -0
- package/dist/lib/components/ZenBadge.d.ts.map +1 -0
- package/dist/lib/components/ZenButton.d.ts +24 -0
- package/dist/lib/components/ZenButton.d.ts.map +1 -0
- package/dist/lib/components/ZenCard.d.ts +36 -0
- package/dist/lib/components/ZenCard.d.ts.map +1 -0
- package/dist/lib/components/ZenDataDisplay.d.ts +50 -0
- package/dist/lib/components/ZenDataDisplay.d.ts.map +1 -0
- package/dist/lib/components/ZenDataTable.d.ts +20 -0
- package/dist/lib/components/ZenDataTable.d.ts.map +1 -0
- package/dist/lib/components/ZenDossierPanel.d.ts +33 -0
- package/dist/lib/components/ZenDossierPanel.d.ts.map +1 -0
- package/dist/lib/components/ZenDropdown.d.ts +19 -0
- package/dist/lib/components/ZenDropdown.d.ts.map +1 -0
- package/dist/lib/components/ZenFeedback.d.ts +24 -0
- package/dist/lib/components/ZenFeedback.d.ts.map +1 -0
- package/dist/lib/components/ZenForms.d.ts +84 -0
- package/dist/lib/components/ZenForms.d.ts.map +1 -0
- package/dist/lib/components/ZenHeading.d.ts +15 -0
- package/dist/lib/components/ZenHeading.d.ts.map +1 -0
- package/dist/lib/components/ZenIcon.d.ts +24 -0
- package/dist/lib/components/ZenIcon.d.ts.map +1 -0
- package/dist/lib/components/ZenInput.d.ts +12 -0
- package/dist/lib/components/ZenInput.d.ts.map +1 -0
- package/dist/lib/components/ZenModal.d.ts +12 -0
- package/dist/lib/components/ZenModal.d.ts.map +1 -0
- package/dist/lib/components/ZenNavbar.d.ts +11 -0
- package/dist/lib/components/ZenNavbar.d.ts.map +1 -0
- package/dist/lib/components/ZenNavigation.d.ts +52 -0
- package/dist/lib/components/ZenNavigation.d.ts.map +1 -0
- package/dist/lib/components/ZenOverlays.d.ts +48 -0
- package/dist/lib/components/ZenOverlays.d.ts.map +1 -0
- package/dist/lib/components/ZenSegmentedControl.d.ts +18 -0
- package/dist/lib/components/ZenSegmentedControl.d.ts.map +1 -0
- package/dist/lib/components/ZenSwitch.d.ts +11 -0
- package/dist/lib/components/ZenSwitch.d.ts.map +1 -0
- package/dist/lib/components/ZenTabs.d.ts +17 -0
- package/dist/lib/components/ZenTabs.d.ts.map +1 -0
- package/dist/lib/components/ZenTimelineCard.d.ts +14 -0
- package/dist/lib/components/ZenTimelineCard.d.ts.map +1 -0
- package/dist/lib/components/ZenToast.d.ts +18 -0
- package/dist/lib/components/ZenToast.d.ts.map +1 -0
- package/dist/lib/cosmos/ZenCosmosCanvas.d.ts +10 -0
- package/dist/lib/cosmos/ZenCosmosCanvas.d.ts.map +1 -0
- package/dist/lib/cosmos/ZenTelemetryHud.d.ts +45 -0
- package/dist/lib/cosmos/ZenTelemetryHud.d.ts.map +1 -0
- package/dist/lib/index.d.ts +33 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +3922 -0
- package/dist/lib/theme/ZenThemeProvider.d.ts +47 -0
- package/dist/lib/theme/ZenThemeProvider.d.ts.map +1 -0
- package/dist/lib/tokens.css +888 -0
- package/dist/lib/universal/zen-universal.d.ts +77 -0
- package/dist/lib/universal/zen-universal.d.ts.map +1 -0
- package/dist/lib/universal/zen-universal.js +18 -0
- package/dist/lib/zen-universal-C0PD8AI9.js +1352 -0
- package/package.json +78 -4
- package/src/lib/audio/ZenAudioController.tsx +114 -0
- package/src/lib/audio/useZenAudioSynth.ts +289 -0
- package/src/lib/capri/capri-engine.ts +939 -0
- package/src/lib/components/ZenAIConstruct.tsx +594 -0
- package/src/lib/components/ZenAmbientBackground.tsx +210 -0
- package/src/lib/components/ZenBadge.tsx +111 -0
- package/src/lib/components/ZenButton.tsx +127 -0
- package/src/lib/components/ZenCard.tsx +180 -0
- package/src/lib/components/ZenDataDisplay.tsx +349 -0
- package/src/lib/components/ZenDataTable.tsx +143 -0
- package/src/lib/components/ZenDossierPanel.tsx +120 -0
- package/src/lib/components/ZenDropdown.tsx +208 -0
- package/src/lib/components/ZenFeedback.tsx +137 -0
- package/src/lib/components/ZenForms.tsx +664 -0
- package/src/lib/components/ZenHeading.tsx +63 -0
- package/src/lib/components/ZenIcon.tsx +220 -0
- package/src/lib/components/ZenInput.tsx +120 -0
- package/src/lib/components/ZenModal.tsx +103 -0
- package/src/lib/components/ZenNavbar.tsx +106 -0
- package/src/lib/components/ZenNavigation.tsx +300 -0
- package/src/lib/components/ZenOverlays.tsx +386 -0
- package/src/lib/components/ZenSegmentedControl.tsx +113 -0
- package/src/lib/components/ZenSwitch.tsx +63 -0
- package/src/lib/components/ZenTabs.tsx +103 -0
- package/src/lib/components/ZenTimelineCard.tsx +64 -0
- package/src/lib/components/ZenToast.tsx +96 -0
- package/src/lib/cosmos/ZenCosmosCanvas.tsx +931 -0
- package/src/lib/cosmos/ZenTelemetryHud.tsx +385 -0
- package/src/lib/index.ts +293 -0
- package/src/lib/styles/zen-tokens.css +888 -0
- package/src/lib/theme/ZenThemeProvider.tsx +328 -0
- package/src/lib/universal/zen-universal.ts +743 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ZenSegmentItem<T extends string> {
|
|
4
|
+
readonly value: T;
|
|
5
|
+
readonly label: ReactNode;
|
|
6
|
+
readonly icon?: ReactNode;
|
|
7
|
+
readonly count?: string | number;
|
|
8
|
+
readonly disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ZenSegmentedControlProps<T extends string> {
|
|
12
|
+
readonly items: readonly ZenSegmentItem<T>[];
|
|
13
|
+
readonly value: T;
|
|
14
|
+
readonly onChange: (nextValue: T) => void;
|
|
15
|
+
readonly ariaLabel: string;
|
|
16
|
+
readonly size?: 'sm' | 'md';
|
|
17
|
+
readonly className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ZenSegmentedControl<T extends string>({
|
|
21
|
+
items,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
ariaLabel,
|
|
25
|
+
size = 'md',
|
|
26
|
+
className = '',
|
|
27
|
+
}: ZenSegmentedControlProps<T>) {
|
|
28
|
+
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
29
|
+
|
|
30
|
+
const enabledIndices = items
|
|
31
|
+
.map((item, idx) => (item.disabled ? -1 : idx))
|
|
32
|
+
.filter((idx) => idx !== -1);
|
|
33
|
+
|
|
34
|
+
const focusAndSelect = (targetIdx: number) => {
|
|
35
|
+
const target = items[targetIdx];
|
|
36
|
+
if (!target || target.disabled) return;
|
|
37
|
+
buttonRefs.current[targetIdx]?.focus();
|
|
38
|
+
onChange(target.value);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
|
|
42
|
+
const pos = enabledIndices.indexOf(idx);
|
|
43
|
+
if (pos === -1) return;
|
|
44
|
+
|
|
45
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
const nextIdx = enabledIndices[(pos + 1) % enabledIndices.length];
|
|
48
|
+
focusAndSelect(nextIdx);
|
|
49
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
const prevIdx =
|
|
52
|
+
enabledIndices[
|
|
53
|
+
(pos - 1 + enabledIndices.length) % enabledIndices.length
|
|
54
|
+
];
|
|
55
|
+
focusAndSelect(prevIdx);
|
|
56
|
+
} else if (e.key === 'Home') {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
focusAndSelect(enabledIndices[0]);
|
|
59
|
+
} else if (e.key === 'End') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
focusAndSelect(enabledIndices[enabledIndices.length - 1]);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const padClass = size === 'sm' ? 'px-2.5 sm:px-3 py-1.5 text-[11px]' : 'px-3.5 sm:px-4 py-2 text-xs';
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
role="radiogroup"
|
|
70
|
+
aria-label={ariaLabel}
|
|
71
|
+
data-zen-occlude="true"
|
|
72
|
+
className={`inline-flex flex-wrap items-center gap-1.5 p-1.5 max-w-full rounded-2xl zen-surface-static ${className}`.trim()}
|
|
73
|
+
>
|
|
74
|
+
{items.map((item, idx) => {
|
|
75
|
+
const isSelected = item.value === value;
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
key={item.value}
|
|
79
|
+
ref={(el) => {
|
|
80
|
+
buttonRefs.current[idx] = el;
|
|
81
|
+
}}
|
|
82
|
+
type="button"
|
|
83
|
+
role="radio"
|
|
84
|
+
aria-checked={isSelected}
|
|
85
|
+
disabled={item.disabled}
|
|
86
|
+
tabIndex={isSelected ? 0 : -1}
|
|
87
|
+
onClick={() => onChange(item.value)}
|
|
88
|
+
onKeyDown={(e) => handleKeyDown(e, idx)}
|
|
89
|
+
className={`zen-focus-ring rounded-xl zen-font-mono ${padClass} inline-flex items-center gap-1.5 transition-all cursor-pointer ${
|
|
90
|
+
isSelected
|
|
91
|
+
? 'bg-[var(--zen-accent-primary)] text-[var(--zen-ink-on-accent)] font-semibold shadow-sm'
|
|
92
|
+
: 'text-[var(--zen-ink-secondary)] hover:text-[var(--zen-ink-primary)] hover:bg-[var(--zen-surface-subtle)]'
|
|
93
|
+
} ${item.disabled ? 'opacity-40 cursor-not-allowed' : ''}`.trim()}
|
|
94
|
+
>
|
|
95
|
+
{item.icon && <span className="shrink-0 inline-flex">{item.icon}</span>}
|
|
96
|
+
<span>{item.label}</span>
|
|
97
|
+
{item.count !== undefined && (
|
|
98
|
+
<span
|
|
99
|
+
className={`px-1.5 py-0.2 rounded-full text-[10px] ${
|
|
100
|
+
isSelected
|
|
101
|
+
? 'bg-black/20 text-[var(--zen-ink-on-accent)]'
|
|
102
|
+
: 'bg-[var(--zen-surface-subtle)] text-[var(--zen-ink-muted)]'
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{item.count}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</button>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useId, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ZenSwitchProps {
|
|
4
|
+
readonly checked: boolean;
|
|
5
|
+
readonly onChange: (nextChecked: boolean) => void;
|
|
6
|
+
readonly label: ReactNode;
|
|
7
|
+
readonly description?: string;
|
|
8
|
+
readonly disabled?: boolean;
|
|
9
|
+
readonly className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ZenSwitch({
|
|
13
|
+
checked,
|
|
14
|
+
onChange,
|
|
15
|
+
label,
|
|
16
|
+
description,
|
|
17
|
+
disabled = false,
|
|
18
|
+
className = '',
|
|
19
|
+
}: ZenSwitchProps) {
|
|
20
|
+
const labelId = useId();
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
data-zen-occlude="true"
|
|
24
|
+
className={`flex items-center justify-between gap-4 ${className}`.trim()}
|
|
25
|
+
>
|
|
26
|
+
<div className="min-w-0">
|
|
27
|
+
<div
|
|
28
|
+
id={labelId}
|
|
29
|
+
className="text-sm font-medium text-[var(--zen-ink-primary)]"
|
|
30
|
+
>
|
|
31
|
+
{label}
|
|
32
|
+
</div>
|
|
33
|
+
{description && (
|
|
34
|
+
<div className="zen-font-mono text-[11px] text-[var(--zen-ink-muted)] mt-0.5">
|
|
35
|
+
{description}
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
role="switch"
|
|
43
|
+
aria-labelledby={labelId}
|
|
44
|
+
aria-checked={checked}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
onClick={() => onChange(!checked)}
|
|
47
|
+
className={`zen-focus-ring relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors cursor-pointer ${
|
|
48
|
+
checked
|
|
49
|
+
? 'bg-[var(--zen-accent-primary)] border-[var(--zen-accent-primary)]'
|
|
50
|
+
: 'bg-[var(--zen-surface-subtle)] border-[var(--zen-border-control)]'
|
|
51
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
52
|
+
>
|
|
53
|
+
<span
|
|
54
|
+
className={`inline-block h-4 w-4 rounded-full transition-transform ${
|
|
55
|
+
checked
|
|
56
|
+
? 'translate-x-5 bg-[var(--zen-ink-on-accent)]'
|
|
57
|
+
: 'translate-x-1 bg-[var(--zen-ink-primary)]'
|
|
58
|
+
}`}
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ZenTabItem<T extends string> {
|
|
4
|
+
readonly id: T;
|
|
5
|
+
readonly label: ReactNode;
|
|
6
|
+
readonly icon?: ReactNode;
|
|
7
|
+
readonly badge?: string | number;
|
|
8
|
+
readonly disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ZenTabsProps<T extends string> {
|
|
12
|
+
readonly tabs: readonly ZenTabItem<T>[];
|
|
13
|
+
readonly activeTab: T;
|
|
14
|
+
readonly onChange: (nextTab: T) => void;
|
|
15
|
+
readonly ariaLabel: string;
|
|
16
|
+
readonly className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ZenTabs<T extends string>({
|
|
20
|
+
tabs,
|
|
21
|
+
activeTab,
|
|
22
|
+
onChange,
|
|
23
|
+
ariaLabel,
|
|
24
|
+
className = '',
|
|
25
|
+
}: ZenTabsProps<T>) {
|
|
26
|
+
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
27
|
+
|
|
28
|
+
const enabledIndices = tabs
|
|
29
|
+
.map((t, idx) => (t.disabled ? -1 : idx))
|
|
30
|
+
.filter((idx) => idx !== -1);
|
|
31
|
+
|
|
32
|
+
const focusAndActivate = (targetIdx: number) => {
|
|
33
|
+
const target = tabs[targetIdx];
|
|
34
|
+
if (!target || target.disabled) return;
|
|
35
|
+
tabRefs.current[targetIdx]?.focus();
|
|
36
|
+
onChange(target.id);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
|
|
40
|
+
const pos = enabledIndices.indexOf(idx);
|
|
41
|
+
if (pos === -1) return;
|
|
42
|
+
|
|
43
|
+
if (e.key === 'ArrowRight') {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
const nextIdx = enabledIndices[(pos + 1) % enabledIndices.length];
|
|
46
|
+
focusAndActivate(nextIdx);
|
|
47
|
+
} else if (e.key === 'ArrowLeft') {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
const prevIdx =
|
|
50
|
+
enabledIndices[
|
|
51
|
+
(pos - 1 + enabledIndices.length) % enabledIndices.length
|
|
52
|
+
];
|
|
53
|
+
focusAndActivate(prevIdx);
|
|
54
|
+
} else if (e.key === 'Home') {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
focusAndActivate(enabledIndices[0]);
|
|
57
|
+
} else if (e.key === 'End') {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
focusAndActivate(enabledIndices[enabledIndices.length - 1]);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
role="tablist"
|
|
66
|
+
aria-label={ariaLabel}
|
|
67
|
+
data-zen-occlude="true"
|
|
68
|
+
className={`flex flex-wrap items-center gap-1.5 max-w-full border-b border-[var(--zen-border-subtle)] pb-2 ${className}`.trim()}
|
|
69
|
+
>
|
|
70
|
+
{tabs.map((tab, idx) => {
|
|
71
|
+
const isSelected = tab.id === activeTab;
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
key={tab.id}
|
|
75
|
+
ref={(el) => {
|
|
76
|
+
tabRefs.current[idx] = el;
|
|
77
|
+
}}
|
|
78
|
+
type="button"
|
|
79
|
+
role="tab"
|
|
80
|
+
aria-selected={isSelected}
|
|
81
|
+
disabled={tab.disabled}
|
|
82
|
+
tabIndex={isSelected ? 0 : -1}
|
|
83
|
+
onClick={() => onChange(tab.id)}
|
|
84
|
+
onKeyDown={(e) => handleKeyDown(e, idx)}
|
|
85
|
+
className={`zen-focus-ring px-3 sm:px-4 py-2 rounded-xl zen-font-mono text-xs inline-flex items-center gap-2 shrink-0 transition-all cursor-pointer ${
|
|
86
|
+
isSelected
|
|
87
|
+
? 'bg-[var(--zen-accent-subtle)] text-[var(--zen-accent-primary)] border border-[var(--zen-accent-border)] font-semibold'
|
|
88
|
+
: 'text-[var(--zen-ink-secondary)] hover:text-[var(--zen-ink-primary)] hover:bg-[var(--zen-surface-subtle)] border border-transparent'
|
|
89
|
+
} ${tab.disabled ? 'opacity-40 cursor-not-allowed' : ''}`.trim()}
|
|
90
|
+
>
|
|
91
|
+
{tab.icon && <span className="shrink-0 inline-flex">{tab.icon}</span>}
|
|
92
|
+
<span>{tab.label}</span>
|
|
93
|
+
{tab.badge !== undefined && (
|
|
94
|
+
<span className="px-1.5 py-0.5 rounded-full text-[10px] bg-[var(--zen-surface-subtle)] text-[var(--zen-ink-muted)]">
|
|
95
|
+
{tab.badge}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ZenCard, ZenCheckList } from './ZenCard';
|
|
3
|
+
import { ZenTagList } from './ZenBadge';
|
|
4
|
+
|
|
5
|
+
export interface ZenTimelineCardProps {
|
|
6
|
+
readonly period: string;
|
|
7
|
+
readonly location: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly institution: string;
|
|
10
|
+
readonly summary: string;
|
|
11
|
+
readonly bullets?: readonly string[];
|
|
12
|
+
readonly tags: readonly string[];
|
|
13
|
+
readonly icon?: ReactNode;
|
|
14
|
+
readonly className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ZenTimelineCard({
|
|
18
|
+
period,
|
|
19
|
+
location,
|
|
20
|
+
title,
|
|
21
|
+
institution,
|
|
22
|
+
summary,
|
|
23
|
+
bullets,
|
|
24
|
+
tags,
|
|
25
|
+
icon,
|
|
26
|
+
className = '',
|
|
27
|
+
}: ZenTimelineCardProps) {
|
|
28
|
+
return (
|
|
29
|
+
<ZenCard
|
|
30
|
+
variant="static"
|
|
31
|
+
padding="md"
|
|
32
|
+
className={`flex flex-col justify-between ${className}`.trim()}
|
|
33
|
+
>
|
|
34
|
+
<div>
|
|
35
|
+
<div className="flex items-center justify-between gap-2 zen-font-mono text-xs mb-4">
|
|
36
|
+
<span className="inline-flex items-center gap-1.5 text-[var(--zen-accent-primary)]">
|
|
37
|
+
{icon}
|
|
38
|
+
<span>{period}</span>
|
|
39
|
+
</span>
|
|
40
|
+
<span className="text-[var(--zen-ink-muted)]">{location}</span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<h3 className="zen-font-display text-xl sm:text-2xl font-bold text-[var(--zen-ink-primary)]">
|
|
44
|
+
{title}
|
|
45
|
+
</h3>
|
|
46
|
+
<div className="zen-font-serif italic text-xl text-[var(--zen-accent-primary)] mt-0.5 mb-3">
|
|
47
|
+
{institution}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<p className="text-sm text-[var(--zen-ink-secondary)] leading-relaxed mb-4">
|
|
51
|
+
{summary}
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
{bullets && bullets.length > 0 && (
|
|
55
|
+
<ZenCheckList items={bullets} className="mb-5" />
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="pt-3 border-t border-[var(--zen-border-subtle)]">
|
|
60
|
+
<ZenTagList tags={tags} />
|
|
61
|
+
</div>
|
|
62
|
+
</ZenCard>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState, useCallback, type ReactNode } from 'react';
|
|
2
|
+
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
export type ZenToastTone = 'info' | 'success' | 'warning';
|
|
5
|
+
|
|
6
|
+
export interface ZenToastItem {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly detail?: string;
|
|
10
|
+
readonly tone?: ZenToastTone;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ZenToastStackProps {
|
|
14
|
+
readonly toasts: readonly ZenToastItem[];
|
|
15
|
+
readonly onDismiss: (id: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TONE_ICONS: Record<ZenToastTone, ReactNode> = {
|
|
19
|
+
info: <Info className="w-4 h-4 text-[var(--zen-status-info)] shrink-0" />,
|
|
20
|
+
success: (
|
|
21
|
+
<CheckCircle2 className="w-4 h-4 text-[var(--zen-status-success)] shrink-0" />
|
|
22
|
+
),
|
|
23
|
+
warning: (
|
|
24
|
+
<AlertTriangle className="w-4 h-4 text-[var(--zen-accent-primary)] shrink-0" />
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ZenToastStack({ toasts, onDismiss }: ZenToastStackProps) {
|
|
29
|
+
if (toasts.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
aria-live="polite"
|
|
34
|
+
data-zen-occlude="true"
|
|
35
|
+
className="fixed bottom-4 inset-x-4 sm:inset-x-auto sm:bottom-6 sm:right-6 z-50 flex flex-col gap-2.5 sm:max-w-sm sm:w-full pointer-events-none"
|
|
36
|
+
>
|
|
37
|
+
{toasts.map((t) => {
|
|
38
|
+
const tone = t.tone ?? 'info';
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
key={t.id}
|
|
42
|
+
role="status"
|
|
43
|
+
className="pointer-events-auto flex items-start justify-between gap-3 p-4 rounded-2xl bg-[var(--zen-bg-elevated)] border border-[var(--zen-border-strong)] shadow-[var(--zen-shadow-hud)] backdrop-blur-xl"
|
|
44
|
+
>
|
|
45
|
+
<div className="flex items-start gap-2.5 min-w-0">
|
|
46
|
+
<div className="mt-0.5">{TONE_ICONS[tone]}</div>
|
|
47
|
+
<div className="min-w-0">
|
|
48
|
+
<div className="zen-font-mono text-xs font-semibold text-[var(--zen-ink-primary)]">
|
|
49
|
+
{t.title}
|
|
50
|
+
</div>
|
|
51
|
+
{t.detail && (
|
|
52
|
+
<div className="text-xs text-[var(--zen-ink-secondary)] mt-0.5 break-words">
|
|
53
|
+
{t.detail}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label="Dismiss notification"
|
|
61
|
+
onClick={() => onDismiss(t.id)}
|
|
62
|
+
className="zen-focus-ring p-1 rounded-lg text-[var(--zen-ink-muted)] hover:text-[var(--zen-ink-primary)] cursor-pointer shrink-0"
|
|
63
|
+
>
|
|
64
|
+
<X className="w-3.5 h-3.5" />
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useZenToast() {
|
|
74
|
+
const [toasts, setToasts] = useState<readonly ZenToastItem[]>([]);
|
|
75
|
+
|
|
76
|
+
const dismissToast = useCallback((id: string) => {
|
|
77
|
+
setToasts((prev) => prev.filter((item) => item.id !== id));
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const pushToast = useCallback(
|
|
81
|
+
(toast: Omit<ZenToastItem, 'id'>) => {
|
|
82
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
83
|
+
setToasts((prev) => [...prev.slice(-3), { ...toast, id }]);
|
|
84
|
+
window.setTimeout(() => {
|
|
85
|
+
dismissToast(id);
|
|
86
|
+
}, 4200);
|
|
87
|
+
},
|
|
88
|
+
[dismissToast]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
toasts,
|
|
93
|
+
pushToast,
|
|
94
|
+
dismissToast,
|
|
95
|
+
};
|
|
96
|
+
}
|