orionfold-relay 0.28.0 → 0.29.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.
Files changed (35) hide show
  1. package/dist/cli.js +5 -5
  2. package/package.json +1 -1
  3. package/src/app/api/instance/identity/route.ts +80 -0
  4. package/src/app/api/settings/glance/route.ts +166 -0
  5. package/src/app/api/telemetry/route.ts +1 -24
  6. package/src/app/globals.css +172 -6
  7. package/src/app/layout.tsx +6 -2
  8. package/src/app/page.tsx +4 -4
  9. package/src/components/apps/kit-view/kit-view.tsx +19 -0
  10. package/src/components/apps/last-run-card.tsx +11 -9
  11. package/src/components/apps/run-now-toast.ts +10 -7
  12. package/src/components/apps/starter-template-card.tsx +1 -1
  13. package/src/components/chat/chat-command-popover.tsx +3 -3
  14. package/src/components/settings/auth-status-dot.tsx +15 -1
  15. package/src/components/shell/app-bar.tsx +10 -7
  16. package/src/components/shell/app-shell.tsx +7 -1
  17. package/src/components/shell/bar-identity-cluster.tsx +48 -0
  18. package/src/components/shell/glance-rail.tsx +304 -0
  19. package/src/components/shell/rail-cell.tsx +8 -4
  20. package/src/components/shell/telemetry-rail.tsx +36 -12
  21. package/src/components/shell/use-instance-identity.ts +107 -0
  22. package/src/components/shell/use-settings-glance.ts +82 -0
  23. package/src/lib/apps/composition-detector.ts +1 -1
  24. package/src/lib/apps/registry.ts +1 -1
  25. package/src/lib/apps/view-kits/kits/workflow-hub.ts +12 -1
  26. package/src/lib/apps/view-kits/types.ts +8 -0
  27. package/src/lib/chat/command-tabs.ts +1 -1
  28. package/src/lib/chat/system-prompt.ts +3 -3
  29. package/src/lib/chat/tool-catalog.ts +9 -9
  30. package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
  31. package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
  32. package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
  33. package/src/lib/plugins/registry.ts +1 -1
  34. package/src/lib/plugins/sdk/types.ts +1 -1
  35. package/src/lib/settings/runtime-setup.ts +32 -0
package/dist/cli.js CHANGED
@@ -1186,7 +1186,7 @@ var CURRENT_PLUGIN_API_VERSION, CAPABILITY_VALUES, ORIGIN_VALUES, PrimitivesBund
1186
1186
  var init_types = __esm({
1187
1187
  "src/lib/plugins/sdk/types.ts"() {
1188
1188
  "use strict";
1189
- CURRENT_PLUGIN_API_VERSION = "0.28";
1189
+ CURRENT_PLUGIN_API_VERSION = "0.29";
1190
1190
  CAPABILITY_VALUES = ["fs", "net", "child_process", "env"];
1191
1191
  ORIGIN_VALUES = ["ainative-internal", "third-party"];
1192
1192
  PrimitivesBundleManifestSchema = z.object({
@@ -3262,7 +3262,7 @@ function buildPrimitivesSummary(manifest) {
3262
3262
  const tableCount = manifest.tables.length;
3263
3263
  const scheduleCount = manifest.schedules.length;
3264
3264
  if (profileCount > 0) {
3265
- parts.push(pluralize(profileCount, "Profile", "profiles"));
3265
+ parts.push(pluralize(profileCount, "Agent", "agents"));
3266
3266
  }
3267
3267
  if (blueprintCount > 0) {
3268
3268
  parts.push(pluralize(blueprintCount, "Blueprint", "blueprints"));
@@ -13010,7 +13010,7 @@ var init_registry6 = __esm({
13010
13010
  init_registry5();
13011
13011
  init_installer();
13012
13012
  init_schedule_spec();
13013
- SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.27"]);
13013
+ SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.28"]);
13014
13014
  pluginCache = null;
13015
13015
  lastLoadedPluginIds = /* @__PURE__ */ new Set();
13016
13016
  PluginTableSchema = z16.object({
@@ -25941,8 +25941,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25941
25941
  import yaml12 from "js-yaml";
25942
25942
  import semver from "semver";
25943
25943
  function relayCoreVersion() {
25944
- if (semver.valid("0.28.0")) {
25945
- return "0.28.0";
25944
+ if (semver.valid("0.29.0")) {
25945
+ return "0.29.0";
25946
25946
  }
25947
25947
  try {
25948
25948
  const root = getAppRoot(import.meta.dirname, 3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,80 @@
1
+ import { NextResponse } from "next/server";
2
+ import { valid as semverValid } from "semver";
3
+ import { relayCoreVersion } from "@/lib/packs/install";
4
+ import { getLicensedIdentity } from "@/lib/licensing/store";
5
+ import { getRuntimeSetupStates, pickActiveRuntime } from "@/lib/settings/runtime-setup";
6
+ import { resolvePreferredModel } from "@/lib/agents/runtime/model-preference";
7
+
8
+ /**
9
+ * GET /api/instance/identity
10
+ *
11
+ * The consolidated instance-identity read for the top-chrome bar cluster and
12
+ * the rail RUNTIME cell. One poll, one loading state, three fields:
13
+ *
14
+ * - version: the running relay-core version ("0.28.0"), or null.
15
+ * - activeModel: the concrete model the active runtime would run ("claude-opus-4-8"),
16
+ * or null when nothing resolves.
17
+ * - licenseTag: a discriminated union — { kind:"licensed", label } or { kind:"community" }.
18
+ *
19
+ * Two shadow-path rules (Engineering Principle #3 — data flows have shadow paths):
20
+ *
21
+ * 1. `version` is null, NEVER "0.0.0". `relayCoreVersion()` falls back to
22
+ * "0.0.0" when the build-time `__RELAY_CORE_VERSION__` global is missing or
23
+ * malformed (the Next 16 `defineServer` raw-string gotcha). We surface that
24
+ * fallback as `null` so the bar renders NOTHING rather than a wrong version.
25
+ * Absent > wrong.
26
+ * 2. `licenseTag` is a discriminated union, never a nullable string, so a
27
+ * missing name can never render as a dangling "Licensed to ". The store's
28
+ * `getLicensedIdentity()` already fails OPEN to community (null); the union
29
+ * makes that community fallback explicit at the type level.
30
+ *
31
+ * This is a live read of mutable server state (license files, runtime auth,
32
+ * model preference) — never cached.
33
+ */
34
+ export const dynamic = "force-dynamic";
35
+
36
+ export type LicenseTag =
37
+ | { kind: "licensed"; label: string }
38
+ | { kind: "community" };
39
+
40
+ export interface InstanceIdentityResponse {
41
+ version: string | null;
42
+ activeModel: string | null;
43
+ licenseTag: LicenseTag;
44
+ }
45
+
46
+ export async function GET() {
47
+ try {
48
+ // Version: treat the "0.0.0" build-fallback as absent (rule 1).
49
+ const rawVersion = relayCoreVersion();
50
+ const version =
51
+ semverValid(rawVersion) && rawVersion !== "0.0.0" ? rawVersion : null;
52
+
53
+ // License tag: fails open to community (rule 2).
54
+ const label = getLicensedIdentity();
55
+ const licenseTag: LicenseTag = label
56
+ ? { kind: "licensed", label }
57
+ : { kind: "community" };
58
+
59
+ // Active model: the concrete model the active runtime would run.
60
+ let activeModel: string | null = null;
61
+ try {
62
+ const states = await getRuntimeSetupStates();
63
+ const { runtimeId } = pickActiveRuntime(states);
64
+ activeModel = (await resolvePreferredModel(runtimeId)).modelId;
65
+ } catch {
66
+ // No runtime configured / preference read failed — leave null; the rail
67
+ // falls back to the runtimeLabel so the cell is never blank.
68
+ activeModel = null;
69
+ }
70
+
71
+ const body: InstanceIdentityResponse = { version, activeModel, licenseTag };
72
+ return NextResponse.json(body);
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ // Zero silent failures — surface the fault to server logs, and let the
76
+ // client hook fall into its `status:"error"` branch (cluster renders nothing).
77
+ console.error("[instance/identity] read failed:", message);
78
+ return NextResponse.json({ error: message }, { status: 500 });
79
+ }
80
+ }
@@ -0,0 +1,166 @@
1
+ import { NextResponse } from "next/server";
2
+ import { count } from "drizzle-orm";
3
+ import { getLicensedIdentity } from "@/lib/licensing/store";
4
+ import {
5
+ getRuntimeSetupStates,
6
+ pickActiveRuntime,
7
+ } from "@/lib/settings/runtime-setup";
8
+ import { resolvePreferredModel } from "@/lib/agents/runtime/model-preference";
9
+ import { getBudgetGuardrailSnapshot } from "@/lib/settings/budget-guardrails";
10
+ import { getRoutingPreference } from "@/lib/settings/routing";
11
+ import { getActivePresets } from "@/lib/settings/permission-presets";
12
+ import { getAllowedPermissions } from "@/lib/settings/permissions";
13
+ import { getSetting } from "@/lib/settings/helpers";
14
+ import { SETTINGS_KEYS, type RoutingPreference } from "@/lib/constants/settings";
15
+ import { db } from "@/lib/db";
16
+ import { channelConfigs } from "@/lib/db/schema";
17
+ import type { LicenseTag } from "@/app/api/instance/identity/route";
18
+
19
+ /**
20
+ * GET /api/settings/glance
21
+ *
22
+ * The consolidated settings read for the FEAT-14 settings-at-a-glance rail.
23
+ * One poll, one loading state, four grouped clusters (Runtime · Budget ·
24
+ * Permissions · Integrations) summarized as compact read-only fields. Aggregates
25
+ * the ~8 settings lib sources SERVER-SIDE (direct lib calls in one Promise.all,
26
+ * never an HTTP fan-out), mirroring /api/instance/identity's discipline.
27
+ *
28
+ * Shadow-path rules (Engineering Principle #3 — data flows have shadow paths):
29
+ * EVERY field is independently nullable, and each source is resolved in its own
30
+ * `.catch(() => null)` so one failing read (e.g. no runtime configured) NULLS
31
+ * only its own field — the rail still renders the chips that resolved. A total
32
+ * route failure returns 500 and the client hook collapses the rail to nothing
33
+ * (no crash, no half-rendered skeleton). Absent > wrong, everywhere.
34
+ *
35
+ * Live read of mutable server state (license files, runtime auth, budget policy,
36
+ * permission allow-list, channel rows) — never cached.
37
+ */
38
+ export const dynamic = "force-dynamic";
39
+
40
+ export type { LicenseTag };
41
+
42
+ export interface SettingsGlanceResponse {
43
+ // Runtime cluster
44
+ activeRuntimeLabel: string | null;
45
+ activeModel: string | null;
46
+ routingPreference: RoutingPreference | null;
47
+ configuredRuntimeCount: number | null;
48
+ sdkTimeoutSeconds: number | null;
49
+ maxTurns: number | null;
50
+ // Budget cluster
51
+ licenseTag: LicenseTag;
52
+ budgetMonthlyCapUsd: number | null;
53
+ // Permissions cluster
54
+ activePreset: string | null; // most-permissive active preset id, or null
55
+ allowedPermissionCount: number | null;
56
+ // Integrations cluster
57
+ webSearchEnabled: boolean | null;
58
+ channelCount: number | null;
59
+ autoPromoteSkills: boolean | null;
60
+ }
61
+
62
+ // The active preset id, choosing the MOST permissive when several are fully
63
+ // satisfied by the current allow-list (full-auto ⊃ git-safe ⊃ read-only), so
64
+ // the glance reports the effective posture rather than a subset preset.
65
+ function pickPreset(activePresets: string[]): string | null {
66
+ for (const id of ["full-auto", "git-safe", "read-only"]) {
67
+ if (activePresets.includes(id)) return id;
68
+ }
69
+ return activePresets[0] ?? null;
70
+ }
71
+
72
+ async function resolveRuntime(): Promise<{
73
+ label: string | null;
74
+ model: string | null;
75
+ configuredCount: number | null;
76
+ }> {
77
+ const states = await getRuntimeSetupStates();
78
+ const { runtimeId, runtimeLabel } = pickActiveRuntime(states);
79
+ // Count runtimes the user has actually set up (any state marked configured).
80
+ const configuredCount = Object.values(states).filter(
81
+ (s) => (s as { configured?: boolean }).configured,
82
+ ).length;
83
+ let model: string | null = null;
84
+ try {
85
+ model = (await resolvePreferredModel(runtimeId)).modelId;
86
+ } catch {
87
+ model = null;
88
+ }
89
+ return { label: runtimeLabel, model, configuredCount };
90
+ }
91
+
92
+ // Parse a numeric setting stored as a string; null/NaN → null (shadow path).
93
+ function numSetting(raw: string | null): number | null {
94
+ if (raw == null) return null;
95
+ const n = Number(raw);
96
+ return Number.isFinite(n) ? n : null;
97
+ }
98
+
99
+ async function countChannels(): Promise<number> {
100
+ const [row] = await db.select({ value: count() }).from(channelConfigs);
101
+ return row?.value ?? 0;
102
+ }
103
+
104
+ export async function GET() {
105
+ try {
106
+ // Each source resolves independently; a single failing read nulls only its
107
+ // own field(s) — the rail renders whatever resolved.
108
+ const [
109
+ runtime,
110
+ label,
111
+ budget,
112
+ routing,
113
+ presets,
114
+ allowed,
115
+ exa,
116
+ channels,
117
+ sdkTimeout,
118
+ maxTurns,
119
+ autoPromote,
120
+ ] = await Promise.all([
121
+ resolveRuntime().catch(() => ({
122
+ label: null,
123
+ model: null,
124
+ configuredCount: null,
125
+ })),
126
+ Promise.resolve(getLicensedIdentity()).catch(() => null),
127
+ getBudgetGuardrailSnapshot().catch(() => null),
128
+ getRoutingPreference().catch(() => null),
129
+ getActivePresets().catch(() => null),
130
+ getAllowedPermissions().catch(() => null),
131
+ getSetting(SETTINGS_KEYS.EXA_SEARCH_MCP_ENABLED).catch(() => null),
132
+ countChannels().catch(() => null),
133
+ getSetting(SETTINGS_KEYS.SDK_TIMEOUT_SECONDS).catch(() => null),
134
+ getSetting(SETTINGS_KEYS.MAX_TURNS).catch(() => null),
135
+ getSetting(SETTINGS_KEYS.AUTO_PROMOTE_SKILLS).catch(() => null),
136
+ ]);
137
+
138
+ const licenseTag: LicenseTag = label
139
+ ? { kind: "licensed", label }
140
+ : { kind: "community" };
141
+
142
+ const body: SettingsGlanceResponse = {
143
+ activeRuntimeLabel: runtime.label,
144
+ activeModel: runtime.model,
145
+ routingPreference: routing,
146
+ configuredRuntimeCount: runtime.configuredCount,
147
+ sdkTimeoutSeconds: numSetting(sdkTimeout),
148
+ maxTurns: numSetting(maxTurns),
149
+ licenseTag,
150
+ budgetMonthlyCapUsd: budget?.policy.overall.monthlySpendCapUsd ?? null,
151
+ activePreset: presets ? pickPreset(presets) : null,
152
+ allowedPermissionCount: allowed?.length ?? null,
153
+ // exa is stored as the string "true"/"false"; null (unread) → null, not false.
154
+ webSearchEnabled: exa == null ? null : exa === "true",
155
+ channelCount: channels,
156
+ autoPromoteSkills: autoPromote == null ? null : autoPromote === "true",
157
+ };
158
+ return NextResponse.json(body);
159
+ } catch (err) {
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ // Zero silent failures — surface to server logs, let the hook fall into
162
+ // status:"error" (the whole rail collapses to nothing).
163
+ console.error("[settings/glance] read failed:", message);
164
+ return NextResponse.json({ error: message }, { status: 500 });
165
+ }
166
+ }
@@ -17,12 +17,7 @@ import {
17
17
  getCompletionsByDay,
18
18
  getFailuresByDay,
19
19
  } from "@/lib/queries/chart-data";
20
- import {
21
- DEFAULT_AGENT_RUNTIME,
22
- SUPPORTED_AGENT_RUNTIMES,
23
- type AgentRuntimeId,
24
- } from "@/lib/agents/runtime/catalog";
25
- import type { RuntimeSetupState } from "@/lib/settings/runtime-setup";
20
+ import { pickActiveRuntime } from "@/lib/settings/runtime-setup";
26
21
  import type { TelemetrySnapshot } from "@/components/shell/telemetry-types";
27
22
 
28
23
  // Telemetry is a live read of mutable server state; never let a route or the
@@ -97,24 +92,6 @@ function getHostMetrics(): { cpuLoadPct: number | null; memUsedPct: number | nul
97
92
  return { cpuLoadPct, memUsedPct };
98
93
  }
99
94
 
100
- // Pick the runtime to surface in the RUNTIME cell: the default (claude-code) if
101
- // it is configured, otherwise the first configured runtime in catalog order, and
102
- // failing that the default's label (so the cell shows "Claude Code · anthropic"
103
- // with the understanding it is not yet set up, rather than an empty cell).
104
- function pickActiveRuntime(
105
- states: Record<AgentRuntimeId, RuntimeSetupState>,
106
- ): { runtimeLabel: string | null; providerId: string | null } {
107
- const ordered: AgentRuntimeId[] = [
108
- DEFAULT_AGENT_RUNTIME,
109
- ...SUPPORTED_AGENT_RUNTIMES.filter((id) => id !== DEFAULT_AGENT_RUNTIME),
110
- ];
111
- const configured = ordered.find((id) => states[id]?.configured);
112
- const chosen = configured ?? DEFAULT_AGENT_RUNTIME;
113
- const state = states[chosen];
114
- if (!state) return { runtimeLabel: null, providerId: null };
115
- return { runtimeLabel: state.label, providerId: state.providerId };
116
- }
117
-
118
95
  export async function GET() {
119
96
  try {
120
97
  const todayMidnight = new Date();
@@ -103,6 +103,25 @@
103
103
  --surface-3: oklch(0.96 0.006 250);
104
104
  --surface-foreground: oklch(0.14 0.02 250);
105
105
 
106
+ /* Instrument palette (WS3, 2026-07-05) — the relay-website technique ported
107
+ into the app: a two-tier teal blueprint grid, translucent teal elevation
108
+ washes, and an accent glow, ALL derived from --primary so they track the
109
+ theme automatically (dark teal vs light teal, no .dark override needed).
110
+ These are ADDITIVE — surfaces stay neutral; the teal lives only in the
111
+ grid/glow/wash LAYERS painted over neutral surfaces (per the website). Light
112
+ washes run slightly hotter than dark since teal-on-white is subtle. */
113
+ --grid-line: color-mix(in oklch, var(--primary) 10%, transparent); /* fine 24px hairline */
114
+ --grid-major: color-mix(in oklch, var(--primary) 18%, transparent); /* major 120px line */
115
+ --glow-accent: color-mix(in oklch, var(--primary) 22%, transparent); /* ambient accent (light) */
116
+ --wash-1: color-mix(in oklch, var(--primary) 5%, transparent); /* faintest teal wash */
117
+ --wash-2: color-mix(in oklch, var(--primary) 8%, transparent);
118
+ --wash-3: color-mix(in oklch, var(--primary) 12%, transparent); /* strongest teal wash */
119
+ /* The translucent instrument rail: a tint of --surface-2 the grid reads
120
+ through, + a faint teal seam-glow on the rail's top edge. */
121
+ /* Rail is now 100% OPAQUE per operator — solid --surface-2, no grid through. */
122
+ --rail-surface: var(--surface-2);
123
+ --rail-glow: color-mix(in oklch, var(--primary) 12%, transparent);
124
+
106
125
  /* Accent */
107
126
  --accent: oklch(0.955 0.01 250);
108
127
  --accent-foreground: oklch(0.25 0.03 250);
@@ -173,6 +192,34 @@
173
192
  --space-10: 40px;
174
193
  --space-12: 48px;
175
194
  --space-16: 64px;
195
+
196
+ /* Top-chrome geometry — the two-tier app bar's real stacked height. The
197
+ telemetry rail's sticky `top` MUST equal this (rail slides UNDER the header
198
+ otherwise). Theme-agnostic, so declared once here, not in .dark. */
199
+ --chrome-tier1: 3.5rem; /* h-14 — tier-1 nav bar */
200
+ --chrome-tier2: 2.75rem; /* h-11 — tier-2 sub-nav */
201
+ --chrome-header: calc(var(--chrome-tier1) + var(--chrome-tier2)); /* 100px */
202
+ /* The telemetry rail's height (was a magic h-[88px]). The settings-glance
203
+ collapsed row parks sticky BELOW the rail, so its `top` is header + rail;
204
+ tokenizing keeps that offset honest if the rail height ever changes. We do
205
+ NOT fold these into --chrome-header — that token is the telemetry rail's own
206
+ `top` and folding rail height in would shove the rail under the header. */
207
+ /* Telemetry rail height. The rail is now auto-height (content + symmetric
208
+ py-2.5), so this MUST equal that rendered height or the glance rail (which
209
+ parks at header + rail) leaves a gap / overlaps. Expressed in rem so it
210
+ tracks the root font like --chrome-header: the rail's 3-line cell +
211
+ 2×0.625rem padding renders ~4.95rem (≈69px at the operator's 14px root). */
212
+ --chrome-rail: 4.95rem;
213
+ --chrome-glance-top: calc(var(--chrome-header) + var(--chrome-rail)); /* glance sticky top */
214
+
215
+ /* Named z-scale — formalizes values previously scattered as magic numbers
216
+ (z-20/z-30/100/9999). Header always wins over rail; grid sits below all
217
+ chrome; overlays and toasts stay on top. */
218
+ --z-canvas-grid: 0;
219
+ --z-rail: 30;
220
+ --z-header: 40;
221
+ --z-overlay: 100;
222
+ --z-toast: 9999;
176
223
  }
177
224
 
178
225
  /* =============================================================
@@ -202,12 +249,33 @@
202
249
  --muted: oklch(0.20 0.02 250); /* Derivation: matches secondary */
203
250
  --muted-foreground: oklch(0.66 0.02 250); /* L0.66 on L0.14 bg ≈ 5.3:1 — clears WCAG AA 4.5:1 even un-diluted (was L0.58 ≈ 3.6:1, failed) */
204
251
 
205
- /* Surfaces — 3-tier dark hierarchy */
206
- --surface-1: oklch(0.18 0.02 250); /* Derivation: base + 0.04 */
207
- --surface-2: oklch(0.16 0.02 250); /* Derivation: base + 0.02 */
208
- --surface-3: oklch(0.14 0.02 250); /* Derivation: equals base */
252
+ /* Surfaces — 3-tier dark hierarchy. Steps widened from 0.02L to ~0.035L:
253
+ near the dark end of oklch the eye reads a 0.02 lightness step as almost
254
+ flat (unlike light mode, where 0.025 near white reads clearly), so the
255
+ chrome tiers looked uniform. s-1 stays 0.18 (card/sidebar baseline no
256
+ shift), s-2/s-3 recede further so the descending elevation is legible. */
257
+ --surface-1: oklch(0.185 0.02 250); /* frontmost — card/sidebar baseline */
258
+ --surface-2: oklch(0.15 0.02 250); /* steps back */
259
+ --surface-3: oklch(0.115 0.018 250); /* deepest — the instrument rail */
209
260
  --surface-foreground: oklch(0.92 0.01 250);
210
261
 
262
+ /* Instrument palette (WS3) — dark. Derived from --primary (dark teal
263
+ oklch 0.78/.13/192). Grid alpha halved from 12/22% → 6/11% per operator
264
+ ("50% darker"): the teal recedes toward the charcoal ground so the grid
265
+ reads subtler/dimmer. Washes track --primary; surfaces stay neutral. */
266
+ --grid-line: color-mix(in oklch, var(--primary) 6%, transparent); /* fine 24px hairline */
267
+ --grid-major: color-mix(in oklch, var(--primary) 11%, transparent); /* major 120px line */
268
+ --glow-accent: color-mix(in oklch, var(--primary) 26%, transparent); /* ambient accent (dark) */
269
+ --wash-1: color-mix(in oklch, var(--primary) 4%, transparent);
270
+ --wash-2: color-mix(in oklch, var(--primary) 6%, transparent);
271
+ --wash-3: color-mix(in oklch, var(--primary) 10%, transparent);
272
+ /* Dark rail: 100% OPAQUE per operator, on --surface-1 (L0.185) — a solid
273
+ lifted chrome band clearly above the canvas --background (0.14). --surface-1
274
+ (not --surface-2, which is only 0.01L above the bg) keeps the band distinct;
275
+ opaque means no grid shows through. */
276
+ --rail-surface: var(--surface-1);
277
+ --rail-glow: color-mix(in oklch, var(--primary) 14%, transparent);
278
+
211
279
  /* Accent */
212
280
  --accent: oklch(0.20 0.02 250);
213
281
  --accent-foreground: oklch(0.92 0.01 250);
@@ -537,7 +605,7 @@
537
605
  .of-boot {
538
606
  position: fixed;
539
607
  inset: 0;
540
- z-index: 100;
608
+ z-index: var(--z-overlay);
541
609
  display: flex;
542
610
  flex-direction: column;
543
611
  align-items: center;
@@ -562,6 +630,104 @@
562
630
  overflow: hidden;
563
631
  }
564
632
 
633
+ /* =============================================================
634
+ BLUEPRINT GRID (FEAT-15 → instrument palette WS3, 2026-07-05)
635
+ Two grids, both FULL-BLEED (no radial mask):
636
+
637
+ • CANVAS (#main-content): the two-tier TEAL grid (fine 24px
638
+ --grid-line + major 120px --grid-major), covering the canvas
639
+ EVENLY edge to edge.
640
+ • RAIL (.rail-instrument): a UNIFORM single-tier 24px grid.
641
+
642
+ NO radial edge-mask on either. The mask faded the grid before the
643
+ centered content edge, leaving a dark ungridded band that read as
644
+ a ~15px "border" framing the container (operator). A uniform grid
645
+ has no fade zone, so no perceived frame. `.rail-instrument` also
646
+ owns position:sticky (avoids the [aria-label] specificity trap
647
+ that clobbered it). Both derive from --primary → light/dark track
648
+ automatically. Painted on ::before at --z-canvas-grid (0).
649
+ ============================================================= */
650
+ #main-content {
651
+ position: relative;
652
+ }
653
+ #main-content::before {
654
+ content: "";
655
+ position: absolute;
656
+ inset: 0;
657
+ z-index: var(--z-canvas-grid);
658
+ pointer-events: none;
659
+ /* Two tiers: fine 24px cells + major 120px cells. */
660
+ background-image:
661
+ repeating-linear-gradient(to right, var(--grid-line) 0 1px, transparent 1px 24px),
662
+ repeating-linear-gradient(to bottom, var(--grid-line) 0 1px, transparent 1px 24px),
663
+ repeating-linear-gradient(to right, var(--grid-major) 0 1px, transparent 1px 120px),
664
+ repeating-linear-gradient(to bottom, var(--grid-major) 0 1px, transparent 1px 120px);
665
+ }
666
+
667
+ /* Translucent instrument rail (WS2) — a tint of --surface-2 the grid reads
668
+ through, so the sparkline "graphs" pop while cell values stay legible + a
669
+ teal seam-glow (inset top) marks the chrome↔canvas seam. `.rail-instrument`
670
+ OWNS position:sticky here (specificity 0,1,0 on the class) instead of the
671
+ telemetry rail joining the grid's `[aria-label]` position:relative rule
672
+ (0,1,1) — that attribute selector out-specifies Tailwind's `.sticky` and
673
+ silently demoted the rail to relative, so it scrolled away. */
674
+ .rail-instrument {
675
+ position: sticky;
676
+ background: var(--rail-surface);
677
+ box-shadow: inset 0 1px 0 0 var(--rail-glow);
678
+ }
679
+ /* Telemetry-cell vertical dividers: --border (L0.26 dark) is too low-contrast
680
+ over the lifted rail band, so bump the rail's own cell dividers to
681
+ --border-strong (L0.32). Scoped to the rail (the [aria-label] attribute
682
+ selector out-specifies Tailwind's `.border-border`, so no source-order
683
+ reliance) — no other border-border consumer shifts. */
684
+ [aria-label="Telemetry"] > * {
685
+ border-right-color: var(--border-strong);
686
+ }
687
+ /* The rail is now 100% OPAQUE (--rail-surface = solid surface), so it carries NO
688
+ grid: an opaque background does not hide a ::before grid (the pseudo paints
689
+ ABOVE the background), so the grid ::before was removed rather than relying on
690
+ opacity. The rail is a clean solid band; the canvas keeps its own grid. */
691
+
692
+ /* =============================================================
693
+ SETTINGS-AT-A-GLANCE RAIL (FEAT-14, WS4 · 2026-07-05)
694
+ A two-level progressive-disclosure rail below the telemetry
695
+ rail. It reads as ONE coherent chrome surface (the same
696
+ translucent instrument family as the telemetry rail), NOT a
697
+ stack of detached cards. Depth is carried by teal WASHES and
698
+ HAIRLINES, never by lighter/darker solid bands (that inverted
699
+ the hierarchy and made pills read as inconsistent dark strips):
700
+ • row collapsed chip row — translucent chrome + seam-glow
701
+ • panel expanded ground — same chrome family as the row
702
+ • tile group cluster — faint teal wash + hairline, no hard card
703
+ • pill key/value line — borderless, a hairline rule between
704
+ rows; the value sits on the tile, not on a solid band
705
+ Collapsed row is sticky under the telemetry rail (top =
706
+ --chrome-glance-top); the expanded panel pushes content (no jank).
707
+ ============================================================= */
708
+ .glance-row {
709
+ /* Translucent chrome over the grid + a teal seam-glow on the TOP
710
+ edge marking the telemetry-rail↔glance seam. */
711
+ background: var(--rail-surface);
712
+ box-shadow: inset 0 1px 0 0 var(--rail-glow);
713
+ }
714
+ .glance-panel {
715
+ /* Expanded ground — the SAME translucent chrome family as the row,
716
+ so panel + row read as one surface (not a lighter/darker band). */
717
+ background: var(--rail-surface);
718
+ }
719
+ /* Compact cell — the SAME grammar as the telemetry RailCell (mono-uppercase
720
+ label over a value, left-aligned, a right-hairline vertical divider between
721
+ cells). Used for BOTH the collapsed summary chips and the expanded panel
722
+ cells, so the glance rail speaks one visual language with the telemetry rail
723
+ above it. The last cell in a row drops its divider. */
724
+ .glance-cell {
725
+ border-right: 1px solid var(--grid-line);
726
+ }
727
+ .glance-cell:last-child {
728
+ border-right: none;
729
+ }
730
+
565
731
  /* =============================================================
566
732
  BASE STYLES
567
733
  ============================================================= */
@@ -587,7 +753,7 @@ body {
587
753
  width: 1px;
588
754
  height: 1px;
589
755
  overflow: hidden;
590
- z-index: 9999;
756
+ z-index: var(--z-toast);
591
757
  }
592
758
  .skip-nav:focus {
593
759
  position: fixed;
@@ -65,8 +65,12 @@ const CRITICAL_THEME_CSS = `
65
65
  color-scheme: dark;
66
66
  --background: oklch(0.14 0.02 250);
67
67
  --foreground: oklch(0.92 0.01 250);
68
- --surface-1: oklch(0.18 0.02 250);
69
- --surface-2: oklch(0.16 0.02 250);
68
+ /* KEEP IN SYNC with globals.css .dark --surface-* (widened tier steps,
69
+ 2026-07-04). This inline critical-CSS wins via html.dark specificity
70
+ during the anti-FOUC window; a stale copy here silently overrides the
71
+ real tokens. */
72
+ --surface-1: oklch(0.185 0.02 250);
73
+ --surface-2: oklch(0.15 0.02 250);
70
74
  --border: oklch(0.26 0.015 250);
71
75
  }
72
76
  /* Root rem base. Fixed 14px left the whole rem-based design tiny on high-res
package/src/app/page.tsx CHANGED
@@ -81,8 +81,8 @@ export default async function HomePage() {
81
81
  // the welcome path so we don't pay the read on every dashboard hit.
82
82
  const starters = listStarters();
83
83
  return (
84
- <div className="bg-background min-h-screen p-4 sm:p-6">
85
- <div className="surface-page-shell min-h-[calc(100dvh-2rem)] rounded-xl p-5 sm:p-6 lg:p-7 space-y-6">
84
+ <div className="bg-background min-h-screen">
85
+ <div className="surface-page-shell min-h-screen p-5 sm:p-6 lg:p-7 space-y-6">
86
86
  <WelcomeLanding starters={starters} />
87
87
  </div>
88
88
  </div>
@@ -176,8 +176,8 @@ export default async function HomePage() {
176
176
  );
177
177
 
178
178
  return (
179
- <div className="bg-background min-h-screen p-4 sm:p-6">
180
- <div className="surface-page-shell min-h-[calc(100dvh-2rem)] rounded-xl p-5 sm:p-6 lg:p-7">
179
+ <div className="bg-background min-h-screen">
180
+ <div className="surface-page-shell min-h-screen p-5 sm:p-6 lg:p-7">
181
181
  <Greeting
182
182
  runningCount={runningResult.count}
183
183
  awaitingCount={awaitingResult.count}
@@ -31,6 +31,25 @@ export function KitView({ model }: KitViewProps) {
31
31
  {model.secondaryLead}
32
32
  </p>
33
33
  )}
34
+ {model.secondarySteps && model.secondarySteps.length > 0 && (
35
+ <ol className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
36
+ {model.secondarySteps.map((step, i) => (
37
+ <li key={step.n} className="flex items-center gap-2">
38
+ <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
39
+ {step.n}
40
+ </span>
41
+ <span className="text-sm text-muted-foreground">
42
+ {step.text}
43
+ </span>
44
+ {i < model.secondarySteps!.length - 1 && (
45
+ <span aria-hidden className="text-muted-foreground/40">
46
+
47
+ </span>
48
+ )}
49
+ </li>
50
+ ))}
51
+ </ol>
52
+ )}
34
53
  <SecondarySlotView slots={model.secondary} />
35
54
  </div>
36
55
  )}
@@ -166,16 +166,18 @@ export function RunnableBlueprintCard({
166
166
  variables={card.variables}
167
167
  label="Run"
168
168
  />
169
- {/* FEAT-6: on the "Start here" card, name both verbs for a
170
- first-time user. Only the primary card carries this hint, so the
171
- grid stays scannable (progressive disclosure). */}
172
- {card.isPrimary && (
173
- <p className="text-xs text-muted-foreground">
174
- {card.variables.length > 0
169
+ {/* CF-FEAT-5: name both verbs on EVERY card, not just the primary,
170
+ so no card leaves the two buttons unexplained. The "Start here"
171
+ card carries the fuller sentence (it may also mention the
172
+ variable prompt); the rest carry a compact one-liner so the grid
173
+ stays scannable (progressive disclosure). */}
174
+ <p className="text-xs text-muted-foreground">
175
+ {card.isPrimary
176
+ ? card.variables.length > 0
175
177
  ? "Run asks a few questions, then starts a workflow you can watch. Create workflow saves a draft to run later."
176
- : "Run starts a workflow you can watch. Create workflow saves a draft to run later."}
177
- </p>
178
- )}
178
+ : "Run starts a workflow you can watch. Create workflow saves a draft to run later."
179
+ : "Run starts it now. Create workflow saves a draft."}
180
+ </p>
179
181
  </div>
180
182
  )}
181
183
  </CardContent>