@nastechai/agent 0.16.0 → 0.17.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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthWidget — sidebar "Logged in as …" affordance for the dashboard
|
|
3
|
+
* OAuth gate (Phase 7 of .nastech/plans/2026-05-21-dashboard-oauth-auth.md).
|
|
4
|
+
*
|
|
5
|
+
* Renders nothing in loopback / --insecure mode. In gated mode, fetches
|
|
6
|
+
* /api/auth/me on mount and surfaces:
|
|
7
|
+
*
|
|
8
|
+
* - the user_id (truncated to 14 chars + ellipsis) since the NasTech Portal
|
|
9
|
+
* contract V1 doesn't emit email/display_name claims (Contract Anchor
|
|
10
|
+
* C4 in the plan; the API responds with empty strings for those
|
|
11
|
+
* fields, so we use user_id as the display value)
|
|
12
|
+
* - the provider's display_name (looked up from /api/auth/providers,
|
|
13
|
+
* defaults to the bare provider key)
|
|
14
|
+
* - a logout button that POSTs /auth/logout and full-page-navigates to
|
|
15
|
+
* /login (the dashboard becomes inaccessible again)
|
|
16
|
+
*
|
|
17
|
+
* Failure modes:
|
|
18
|
+
* - 401 from /api/auth/me means we're not gated (or the gate is on but
|
|
19
|
+
* we have no cookie — in that case the gate's middleware would have
|
|
20
|
+
* redirected us before App.tsx renders, so we won't see this). The
|
|
21
|
+
* widget renders nothing.
|
|
22
|
+
* - Network error: shows a minimal "auth status unavailable" message
|
|
23
|
+
* so the user knows the widget tried.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect, useState } from "react";
|
|
27
|
+
import { api, type AuthMeResponse } from "@/lib/api";
|
|
28
|
+
import { cn } from "@/lib/utils";
|
|
29
|
+
import { LogOut } from "lucide-react";
|
|
30
|
+
|
|
31
|
+
interface AuthWidgetProps {
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Truncate ``user_id`` to fit a small UI without revealing the full
|
|
36
|
+
* opaque identifier. 14 chars is enough to disambiguate users in a
|
|
37
|
+
* small org and short enough to fit a single sidebar row. */
|
|
38
|
+
function truncateUserId(id: string): string {
|
|
39
|
+
if (id.length <= 14) return id;
|
|
40
|
+
return `${id.slice(0, 14)}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function AuthWidget({ className }: AuthWidgetProps) {
|
|
44
|
+
const [me, setMe] = useState<AuthMeResponse | null>(null);
|
|
45
|
+
const [hidden, setHidden] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
api
|
|
51
|
+
.getAuthMe()
|
|
52
|
+
.then((data) => {
|
|
53
|
+
if (cancelled) return;
|
|
54
|
+
setMe(data);
|
|
55
|
+
})
|
|
56
|
+
.catch((err: unknown) => {
|
|
57
|
+
if (cancelled) return;
|
|
58
|
+
// 401 from /api/auth/me means the gate isn't engaged in this
|
|
59
|
+
// process (loopback mode) — render nothing. fetchJSON throws an
|
|
60
|
+
// Error with the status code as a prefix; the global 401
|
|
61
|
+
// handler only redirects on the structured envelope, so a plain
|
|
62
|
+
// 401 from /api/auth/me with no envelope bubbles up here.
|
|
63
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
64
|
+
if (msg.startsWith("401:") || msg.startsWith("403:")) {
|
|
65
|
+
setHidden(true);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setError("auth status unavailable");
|
|
69
|
+
});
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
if (hidden) return null;
|
|
76
|
+
|
|
77
|
+
if (error) {
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{error}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!me) {
|
|
91
|
+
// Loading. Reserve the row height so the sidebar doesn't flicker
|
|
92
|
+
// when the data arrives.
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={cn(
|
|
96
|
+
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
aria-busy="true"
|
|
100
|
+
>
|
|
101
|
+
…
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleLogout = () => {
|
|
107
|
+
void api.logout();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Prefer display_name → email → truncated user_id. Contract V1 only
|
|
111
|
+
// populates user_id; the fallthroughs are forward-compat for a future
|
|
112
|
+
// Portal that adds a userinfo endpoint (OQ-C1 in the plan).
|
|
113
|
+
const label = me.display_name || me.email || truncateUserId(me.user_id);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={cn(
|
|
118
|
+
"flex shrink-0 items-center justify-between gap-2",
|
|
119
|
+
"px-5 py-2",
|
|
120
|
+
"border-t border-current/10",
|
|
121
|
+
"text-[0.65rem] tracking-[0.05em]",
|
|
122
|
+
className,
|
|
123
|
+
)}
|
|
124
|
+
role="status"
|
|
125
|
+
aria-label={`Logged in as ${label}`}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex min-w-0 flex-col">
|
|
128
|
+
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
|
|
129
|
+
{label}
|
|
130
|
+
</span>
|
|
131
|
+
<span className="truncate text-muted-foreground/70">
|
|
132
|
+
via {me.provider}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={handleLogout}
|
|
138
|
+
className={cn(
|
|
139
|
+
"shrink-0 rounded p-1.5 text-muted-foreground/70",
|
|
140
|
+
"transition-colors hover:bg-current/10 hover:text-foreground",
|
|
141
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
|
|
142
|
+
)}
|
|
143
|
+
aria-label="Log out"
|
|
144
|
+
title="Log out"
|
|
145
|
+
>
|
|
146
|
+
<LogOut className="h-3.5 w-3.5" />
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
|
|
2
|
+
import { Switch } from "@nastechai/ui/ui/components/switch";
|
|
3
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
4
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
5
|
+
|
|
6
|
+
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
|
7
|
+
const keyPath = schemaKey.includes(".") ? schemaKey : "";
|
|
8
|
+
const description = schema.description ? String(schema.description) : "";
|
|
9
|
+
|
|
10
|
+
if (!keyPath && !description) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col gap-0.5">
|
|
14
|
+
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
|
|
15
|
+
{description && <span className="text-xs text-text-secondary">{description}</span>}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatScalar(value: unknown): string {
|
|
25
|
+
if (value === undefined || value === null) return "";
|
|
26
|
+
if (typeof value === "string") return value;
|
|
27
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function NestedValueEditor({
|
|
32
|
+
fieldKey,
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
}: {
|
|
36
|
+
fieldKey: string;
|
|
37
|
+
value: unknown;
|
|
38
|
+
onChange: (v: unknown) => void;
|
|
39
|
+
}) {
|
|
40
|
+
if (isRecord(value)) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="grid gap-2 border border-border p-2">
|
|
43
|
+
{Object.entries(value).map(([subKey, subVal]) => (
|
|
44
|
+
<div key={subKey} className="grid gap-1">
|
|
45
|
+
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
|
46
|
+
<NestedValueEditor
|
|
47
|
+
fieldKey={`${fieldKey}.${subKey}`}
|
|
48
|
+
value={subVal}
|
|
49
|
+
onChange={(next) => onChange({ ...value, [subKey]: next })}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="grid gap-2">
|
|
60
|
+
{value.map((item, index) => (
|
|
61
|
+
<div key={`${fieldKey}.${index}`} className="grid gap-1">
|
|
62
|
+
<Label className="text-xs text-muted-foreground">Item {index + 1}</Label>
|
|
63
|
+
<NestedValueEditor
|
|
64
|
+
fieldKey={`${fieldKey}.${index}`}
|
|
65
|
+
value={item}
|
|
66
|
+
onChange={(next) =>
|
|
67
|
+
onChange(value.map((existing, i) => (i === index ? next : existing)))
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Input
|
|
78
|
+
value={formatScalar(value)}
|
|
79
|
+
onChange={(e) => onChange(e.target.value)}
|
|
80
|
+
className="text-xs"
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function AutoField({
|
|
86
|
+
schemaKey,
|
|
87
|
+
schema,
|
|
88
|
+
value,
|
|
89
|
+
onChange,
|
|
90
|
+
}: AutoFieldProps) {
|
|
91
|
+
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
|
|
92
|
+
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
93
|
+
|
|
94
|
+
if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="grid gap-3 border border-border p-3">
|
|
97
|
+
<Label className="text-xs font-medium">{label}</Label>
|
|
98
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
99
|
+
<NestedValueEditor fieldKey={schemaKey} value={value} onChange={onChange} />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (schema.type === "boolean") {
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex items-center justify-between gap-4">
|
|
107
|
+
<div className="flex flex-col gap-0.5">
|
|
108
|
+
<Label className="text-sm">{label}</Label>
|
|
109
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
110
|
+
</div>
|
|
111
|
+
<Switch checked={!!value} onCheckedChange={onChange} />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (schema.type === "select") {
|
|
117
|
+
const options = (schema.options as string[]) ?? [];
|
|
118
|
+
return (
|
|
119
|
+
<div className="grid gap-1.5">
|
|
120
|
+
<Label className="text-sm">{label}</Label>
|
|
121
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
122
|
+
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
|
|
123
|
+
{options.map((opt) => (
|
|
124
|
+
<SelectOption key={opt} value={opt}>
|
|
125
|
+
{opt || "(none)"}
|
|
126
|
+
</SelectOption>
|
|
127
|
+
))}
|
|
128
|
+
</Select>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (schema.type === "number") {
|
|
134
|
+
return (
|
|
135
|
+
<div className="grid gap-1.5">
|
|
136
|
+
<Label className="text-sm">{label}</Label>
|
|
137
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
138
|
+
<Input
|
|
139
|
+
type="number"
|
|
140
|
+
value={value === undefined || value === null ? "" : String(value)}
|
|
141
|
+
onChange={(e) => {
|
|
142
|
+
const raw = e.target.value;
|
|
143
|
+
if (raw === "") {
|
|
144
|
+
onChange(0);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const n = Number(raw);
|
|
148
|
+
if (!Number.isNaN(n)) {
|
|
149
|
+
onChange(n);
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (schema.type === "text") {
|
|
158
|
+
return (
|
|
159
|
+
<div className="grid gap-1.5">
|
|
160
|
+
<Label className="text-sm">{label}</Label>
|
|
161
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
162
|
+
<textarea
|
|
163
|
+
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
164
|
+
value={String(value ?? "")}
|
|
165
|
+
onChange={(e) => onChange(e.target.value)}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (schema.type === "list") {
|
|
172
|
+
return (
|
|
173
|
+
<div className="grid gap-1.5">
|
|
174
|
+
<Label className="text-sm">{label}</Label>
|
|
175
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
176
|
+
<Input
|
|
177
|
+
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
|
|
178
|
+
onChange={(e) =>
|
|
179
|
+
onChange(
|
|
180
|
+
e.target.value
|
|
181
|
+
.split(",")
|
|
182
|
+
.map((s) => s.trim())
|
|
183
|
+
.filter(Boolean),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
placeholder="comma-separated values"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="grid gap-1.5">
|
|
194
|
+
<Label className="text-sm">{label}</Label>
|
|
195
|
+
<FieldHint schema={schema} schemaKey={schemaKey} />
|
|
196
|
+
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface AutoFieldProps {
|
|
202
|
+
schemaKey: string;
|
|
203
|
+
schema: Record<string, unknown>;
|
|
204
|
+
value: unknown;
|
|
205
|
+
onChange: (v: unknown) => void;
|
|
206
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useGpuTier } from "@nastechai/ui/hooks/use-gpu-tier";
|
|
2
|
+
|
|
3
|
+
import fillerBgUrl from "@nastechai/ui/assets/filler-bg0.webp";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replicates the visual layer stack of `<Overlays dark />` from
|
|
7
|
+
* `@nastechai/ui` without pulling in its leva / gsap / three peer deps.
|
|
8
|
+
*
|
|
9
|
+
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
|
|
10
|
+
* truth. Defaults match LENS_0 (the NasTech teal dark preset); the deep canvas
|
|
11
|
+
* and the warm vignette both read theme-switchable CSS custom properties so
|
|
12
|
+
* `ThemeProvider` can repaint the stack without remounting.
|
|
13
|
+
*
|
|
14
|
+
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
|
|
15
|
+
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
|
|
16
|
+
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
|
17
|
+
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
|
18
|
+
* color-dodge) — gated on GPU tier
|
|
19
|
+
*
|
|
20
|
+
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
|
|
21
|
+
* software rasterizer (SwiftShader/llvmpipe), or the user has
|
|
22
|
+
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
|
|
23
|
+
* in that case so low-power / accessibility-conscious sessions stay crisp,
|
|
24
|
+
* mirroring the DS `<Noise />` component's own opt-out.
|
|
25
|
+
*/
|
|
26
|
+
export function Backdrop() {
|
|
27
|
+
const gpuTier = useGpuTier();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<div
|
|
32
|
+
aria-hidden
|
|
33
|
+
className="pointer-events-none fixed inset-0 z-[1]"
|
|
34
|
+
style={{
|
|
35
|
+
backgroundColor: "var(--background-base)",
|
|
36
|
+
mixBlendMode: "difference",
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<div
|
|
41
|
+
aria-hidden
|
|
42
|
+
className="pointer-events-none fixed inset-0 z-[2]"
|
|
43
|
+
style={
|
|
44
|
+
{
|
|
45
|
+
// Themes can override the filler background by setting
|
|
46
|
+
// `assets.bg` — the <img> hides itself when a CSS bg is set
|
|
47
|
+
// so the two don't double-darken. CSS var fallbacks keep the
|
|
48
|
+
// default behaviour unchanged when no theme customises these.
|
|
49
|
+
mixBlendMode:
|
|
50
|
+
"var(--component-backdrop-filler-blend-mode, difference)",
|
|
51
|
+
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
|
|
52
|
+
backgroundImage: "var(--theme-asset-bg)",
|
|
53
|
+
backgroundSize: "var(--component-backdrop-background-size, cover)",
|
|
54
|
+
backgroundPosition:
|
|
55
|
+
"var(--component-backdrop-background-position, center)",
|
|
56
|
+
} as unknown as React.CSSProperties
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<img
|
|
60
|
+
alt=""
|
|
61
|
+
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
|
|
62
|
+
fetchPriority="low"
|
|
63
|
+
src={fillerBgUrl}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div
|
|
68
|
+
aria-hidden
|
|
69
|
+
className="pointer-events-none fixed inset-0 z-[99]"
|
|
70
|
+
style={{
|
|
71
|
+
background:
|
|
72
|
+
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
|
|
73
|
+
mixBlendMode: "lighten",
|
|
74
|
+
opacity: 0.22,
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
{gpuTier > 0 && (
|
|
79
|
+
<div
|
|
80
|
+
aria-hidden
|
|
81
|
+
className="pointer-events-none fixed inset-0 z-[101]"
|
|
82
|
+
style={{
|
|
83
|
+
backgroundImage:
|
|
84
|
+
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
|
|
85
|
+
backgroundSize: "512px 512px",
|
|
86
|
+
mixBlendMode: "color-dodge",
|
|
87
|
+
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|