@guuey/agent-layout 0.16.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 +21 -0
- package/README.md +60 -0
- package/dist/bind.d.ts +56 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/machine.d.ts +59 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +52 -0
- package/dist/react.d.ts +109 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +121 -0
- package/dist/tones.d.ts +79 -0
- package/dist/tones.d.ts.map +1 -0
- package/dist/tones.js +131 -0
- package/package.json +55 -0
- package/src/bind.ts +73 -0
- package/src/index.ts +54 -0
- package/src/machine.ts +76 -0
- package/src/react.tsx +305 -0
- package/src/tones.ts +156 -0
- package/styles.css +180 -0
package/src/react.tsx
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@guuey/agent-layout/react` — the Provider + primitives (guuey#403 §4).
|
|
3
|
+
* React DOM only, by design: cross-platform semantics live in
|
|
4
|
+
* `@guuey/chat`'s selection contract (portal's consult on the proposal).
|
|
5
|
+
*
|
|
6
|
+
* Engineering shape (ggui#633's scar, adopted): the active-panel state
|
|
7
|
+
* lives in its OWN provider, separate from any chat/transcript state —
|
|
8
|
+
* shells must never re-render per streaming token. The context value
|
|
9
|
+
* changes ONLY on machine transitions; every callback identity is stable.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
createContext,
|
|
13
|
+
useCallback,
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useReducer,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
type CSSProperties,
|
|
21
|
+
type HTMLAttributes,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from "react";
|
|
24
|
+
import {
|
|
25
|
+
agentModeReduce,
|
|
26
|
+
INITIAL_AGENT_MODE_STATE,
|
|
27
|
+
type ActivePanel,
|
|
28
|
+
type AgentModeInput,
|
|
29
|
+
type AgentModeState,
|
|
30
|
+
} from "./machine.js";
|
|
31
|
+
import { assertToneFloor, DEFAULT_TONES, type TonePair } from "./tones.js";
|
|
32
|
+
import { DEFAULT_TONE_TRANSITION_MS, LAYOUT_TOKENS } from "./index.js";
|
|
33
|
+
|
|
34
|
+
interface AgentModeContextValue {
|
|
35
|
+
state: AgentModeState;
|
|
36
|
+
dispatch: (input: AgentModeInput) => void;
|
|
37
|
+
drawerOpen: boolean;
|
|
38
|
+
setDrawerOpen: (open: boolean) => void;
|
|
39
|
+
identity: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AgentModeContext = createContext<AgentModeContextValue | null>(null);
|
|
43
|
+
|
|
44
|
+
export interface AgentModeProviderProps {
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* The presentation mode — the SAME host-resolved value the surface
|
|
48
|
+
* passes to `<GuueyChat mode>`; this lib rides the existing mode
|
|
49
|
+
* machinery and adds none of its own (the ggui#633 binding rule: never
|
|
50
|
+
* fixed-dark, never a second mode system). Default `"light"`, matching
|
|
51
|
+
* the kit's own default.
|
|
52
|
+
*/
|
|
53
|
+
mode?: "light" | "dark";
|
|
54
|
+
/**
|
|
55
|
+
* Tone override pair for the CURRENT mode (host-palette / app-theme
|
|
56
|
+
* tiers — §2's tier chain). Validated against the perceptibility floor
|
|
57
|
+
* at wiring time (reject-under-floor, explanatory throw). Base defaults
|
|
58
|
+
* (the ggui#633 shipped pair + its dark mirror) apply when absent.
|
|
59
|
+
*/
|
|
60
|
+
tones?: TonePair;
|
|
61
|
+
/** The follow fade in ms — default the founder-certified eased 150. */
|
|
62
|
+
transitionMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* The surface's identity mark (logo / brand node) for the working state
|
|
65
|
+
* (founder (d)): shown with the built-in spinner while the agent has the
|
|
66
|
+
* room but nothing is presented yet.
|
|
67
|
+
*/
|
|
68
|
+
identity?: ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* The route-derived follow signal (ggui#633's scar: hand-wiring
|
|
71
|
+
* per-link missed 20+ surfaces — sub-nav tabs, in-content links,
|
|
72
|
+
* `router.push`). Pass your router's location key (`usePathname()`,
|
|
73
|
+
* `useLocation().key`, …): ANY change dispatches `menuInteraction`.
|
|
74
|
+
* The initial value dispatches nothing.
|
|
75
|
+
*/
|
|
76
|
+
navigationKey?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function AgentModeProvider({
|
|
80
|
+
children,
|
|
81
|
+
mode = "light",
|
|
82
|
+
tones,
|
|
83
|
+
transitionMs = DEFAULT_TONE_TRANSITION_MS,
|
|
84
|
+
identity = null,
|
|
85
|
+
navigationKey,
|
|
86
|
+
}: AgentModeProviderProps): ReactNode {
|
|
87
|
+
const [state, dispatch] = useReducer(agentModeReduce, INITIAL_AGENT_MODE_STATE);
|
|
88
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
89
|
+
|
|
90
|
+
// Route-derived follow: any CHANGE of the key = the user navigated.
|
|
91
|
+
const navRef = useRef({ initial: true, key: navigationKey });
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (navRef.current.initial) {
|
|
94
|
+
navRef.current = { initial: false, key: navigationKey };
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (Object.is(navRef.current.key, navigationKey)) return;
|
|
98
|
+
navRef.current.key = navigationKey;
|
|
99
|
+
dispatch({ type: "menuInteraction" });
|
|
100
|
+
setDrawerOpen(false);
|
|
101
|
+
}, [navigationKey]);
|
|
102
|
+
|
|
103
|
+
const resolvedTones = useMemo(() => {
|
|
104
|
+
const pair = tones ?? DEFAULT_TONES[mode];
|
|
105
|
+
// Reject-under-floor at wiring time — an override that measures alike
|
|
106
|
+
// defeats the category (§2); the base defaults pass by construction.
|
|
107
|
+
if (tones !== undefined) assertToneFloor(tones);
|
|
108
|
+
return pair;
|
|
109
|
+
}, [tones, mode]);
|
|
110
|
+
|
|
111
|
+
const value = useMemo<AgentModeContextValue>(
|
|
112
|
+
() => ({ state, dispatch, drawerOpen, setDrawerOpen, identity }),
|
|
113
|
+
[state, drawerOpen, identity],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Token application (§2): tones + transition as inline custom properties
|
|
117
|
+
// on the shell scope; `pane-tone` is LIB-WRITTEN from the machine state.
|
|
118
|
+
const vars = {
|
|
119
|
+
[LAYOUT_TOKENS.toneUpper]: resolvedTones.upper,
|
|
120
|
+
[LAYOUT_TOKENS.toneUpperOn]: resolvedTones.upperOn,
|
|
121
|
+
[LAYOUT_TOKENS.toneLower]: resolvedTones.lower,
|
|
122
|
+
[LAYOUT_TOKENS.toneLowerOn]: resolvedTones.lowerOn,
|
|
123
|
+
[LAYOUT_TOKENS.paneTone]:
|
|
124
|
+
state.activePanel === "agent" ? resolvedTones.lower : resolvedTones.upper,
|
|
125
|
+
[LAYOUT_TOKENS.toneTransition]: `${transitionMs}ms`,
|
|
126
|
+
} as CSSProperties;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<AgentModeContext.Provider value={value}>
|
|
130
|
+
<div className="guuey-agent-layout" data-mode={mode} data-active-panel={state.activePanel} style={vars}>
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
</AgentModeContext.Provider>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UseAgentModeResult {
|
|
138
|
+
activePanel: ActivePanel;
|
|
139
|
+
/** A turn is in flight (submit → settled). */
|
|
140
|
+
streaming: boolean;
|
|
141
|
+
/** The founder-(d) working-state window is open. */
|
|
142
|
+
pending: boolean;
|
|
143
|
+
/** The raw machine door — `bindGuueyChat(dispatch)` wires a kit surface. */
|
|
144
|
+
dispatch: (input: AgentModeInput) => void;
|
|
145
|
+
/** Escape hatch for surfaces with custom needs (documented as rarely needed). */
|
|
146
|
+
setActivePanel: (panel: ActivePanel) => void;
|
|
147
|
+
/** The <1024px overlay drawer (lib-owned state; Shell renders the toggle). */
|
|
148
|
+
drawerOpen: boolean;
|
|
149
|
+
setDrawerOpen: (open: boolean) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function useAgentMode(): UseAgentModeResult {
|
|
153
|
+
const ctx = useContext(AgentModeContext);
|
|
154
|
+
if (ctx === null) {
|
|
155
|
+
throw new Error("useAgentMode: no <AgentModeProvider> above this component.");
|
|
156
|
+
}
|
|
157
|
+
const { state, dispatch, drawerOpen, setDrawerOpen } = ctx;
|
|
158
|
+
const setActivePanel = useCallback(
|
|
159
|
+
(panel: ActivePanel) =>
|
|
160
|
+
dispatch(panel === "app" ? { type: "menuInteraction" } : { type: "agentSubmit" }),
|
|
161
|
+
[dispatch],
|
|
162
|
+
);
|
|
163
|
+
return {
|
|
164
|
+
activePanel: state.activePanel,
|
|
165
|
+
streaming: state.streaming,
|
|
166
|
+
pending: state.pending,
|
|
167
|
+
dispatch,
|
|
168
|
+
setActivePanel,
|
|
169
|
+
drawerOpen,
|
|
170
|
+
setDrawerOpen,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The grid: sidebar column + pane. Below 1024px (the console family's own
|
|
176
|
+
* sidebar boundary — one muscle memory across surfaces) the sidebar leaves
|
|
177
|
+
* the grid and becomes an overlay drawer, and the follow is SUSPENDED —
|
|
178
|
+
* the pane holds the lower (agent) tone (stylesheet-enforced; §4's ruled
|
|
179
|
+
* degraded mode). The drawer toggle renders only at drawer widths.
|
|
180
|
+
*
|
|
181
|
+
* Children: an {@link AgentModeSidebar} (wrapping the two panels) and an
|
|
182
|
+
* {@link ActivePane}. The wrapper is structural — the drawer must slide as
|
|
183
|
+
* ONE element, so the two panels share a positioned parent (the §4 sketch
|
|
184
|
+
* elides it; the contract is unchanged).
|
|
185
|
+
*/
|
|
186
|
+
export function AgentModeShell({
|
|
187
|
+
children,
|
|
188
|
+
className,
|
|
189
|
+
...rest
|
|
190
|
+
}: HTMLAttributes<HTMLDivElement>): ReactNode {
|
|
191
|
+
const { drawerOpen, setDrawerOpen } = useAgentMode();
|
|
192
|
+
return (
|
|
193
|
+
<div
|
|
194
|
+
{...rest}
|
|
195
|
+
className={joinClass("guuey-layout-shell", className)}
|
|
196
|
+
data-drawer-open={drawerOpen ? "true" : undefined}
|
|
197
|
+
>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
className="guuey-layout-drawer-toggle"
|
|
201
|
+
aria-expanded={drawerOpen}
|
|
202
|
+
aria-controls="guuey-layout-sidebar"
|
|
203
|
+
onClick={() => setDrawerOpen(!drawerOpen)}
|
|
204
|
+
>
|
|
205
|
+
<span aria-hidden="true">☰</span>
|
|
206
|
+
<span className="guuey-layout-sr-only">Menu</span>
|
|
207
|
+
</button>
|
|
208
|
+
{children}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The sidebar column: the two panels' shared, drawer-slidable parent. */
|
|
214
|
+
export function AgentModeSidebar({
|
|
215
|
+
children,
|
|
216
|
+
className,
|
|
217
|
+
...rest
|
|
218
|
+
}: HTMLAttributes<HTMLElement>): ReactNode {
|
|
219
|
+
return (
|
|
220
|
+
<aside
|
|
221
|
+
{...rest}
|
|
222
|
+
id="guuey-layout-sidebar"
|
|
223
|
+
className={joinClass("guuey-layout-sidebar", className)}
|
|
224
|
+
>
|
|
225
|
+
{children}
|
|
226
|
+
</aside>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface SidebarPanelProps extends HTMLAttributes<HTMLDivElement> {
|
|
231
|
+
section: "app" | "agent";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One sidebar section. `section="app"` wires `menuInteraction` on
|
|
236
|
+
* pointer/focus interactions inside it (capture-phase — apps write ZERO
|
|
237
|
+
* per-link wiring; this covers same-page clicks the route signal cannot
|
|
238
|
+
* see). `section="agent"` hosts the agent surface and wires nothing — the
|
|
239
|
+
* agent bridge speaks through the machine.
|
|
240
|
+
*/
|
|
241
|
+
export function SidebarPanel({ section, children, className, ...rest }: SidebarPanelProps): ReactNode {
|
|
242
|
+
const { dispatch } = useAgentMode();
|
|
243
|
+
const onMenuInteraction =
|
|
244
|
+
section === "app" ? () => dispatch({ type: "menuInteraction" }) : undefined;
|
|
245
|
+
return (
|
|
246
|
+
<div
|
|
247
|
+
{...rest}
|
|
248
|
+
className={joinClass(`guuey-layout-panel guuey-layout-panel-${section}`, className)}
|
|
249
|
+
onPointerDownCapture={onMenuInteraction}
|
|
250
|
+
onFocusCapture={onMenuInteraction}
|
|
251
|
+
>
|
|
252
|
+
{children}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ActivePaneProps extends HTMLAttributes<HTMLDivElement> {
|
|
258
|
+
/**
|
|
259
|
+
* Replaces the built-in working-state treatment (identity + pulse) shown
|
|
260
|
+
* while the agent has the room and nothing is presented yet (founder
|
|
261
|
+
* (d): NEVER hold prior page content on the agent ground).
|
|
262
|
+
*/
|
|
263
|
+
workingState?: ReactNode;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The right pane: paints `--guuey-layout-pane-tone`, animates per the
|
|
268
|
+
* transition token, honors `prefers-reduced-motion` (stylesheet: instant
|
|
269
|
+
* snap). While the founder-(d) window is open the pane presents the
|
|
270
|
+
* working state INSTEAD of its children — prior page content never sits
|
|
271
|
+
* on the agent ground.
|
|
272
|
+
*/
|
|
273
|
+
export function ActivePane({ children, workingState, className, ...rest }: ActivePaneProps): ReactNode {
|
|
274
|
+
const ctx = useContext(AgentModeContext);
|
|
275
|
+
if (ctx === null) throw new Error("ActivePane: no <AgentModeProvider> above this component.");
|
|
276
|
+
const { state, identity } = ctx;
|
|
277
|
+
const working = state.activePanel === "agent" && state.pending;
|
|
278
|
+
return (
|
|
279
|
+
<main {...rest} className={joinClass("guuey-layout-pane", className)}>
|
|
280
|
+
{working ? (
|
|
281
|
+
(workingState ?? (
|
|
282
|
+
<div className="guuey-layout-working" role="status">
|
|
283
|
+
{identity !== null ? <div className="guuey-layout-working-identity">{identity}</div> : null}
|
|
284
|
+
<span className="guuey-layout-working-pulse" aria-hidden="true">
|
|
285
|
+
<span />
|
|
286
|
+
<span />
|
|
287
|
+
<span />
|
|
288
|
+
</span>
|
|
289
|
+
<span className="guuey-layout-sr-only">Working…</span>
|
|
290
|
+
</div>
|
|
291
|
+
))
|
|
292
|
+
) : (
|
|
293
|
+
children
|
|
294
|
+
)}
|
|
295
|
+
</main>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function joinClass(base: string, extra: string | undefined): string {
|
|
300
|
+
return extra === undefined || extra === "" ? base : `${base} ${extra}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export { bindGuueyChat } from "./bind.js";
|
|
304
|
+
export type { AgentModeInput, AgentModeState, ActivePanel } from "./machine.js";
|
|
305
|
+
export type { TonePair } from "./tones.js";
|
package/src/tones.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tone math (guuey#403 §2) — the two-panel tone pair, its perceptibility
|
|
3
|
+
* floor, and the chat-palette derivation.
|
|
4
|
+
*
|
|
5
|
+
* The floor is CALIBRATED, not invented (ggui#633's scars): the console's
|
|
6
|
+
* first cut at ΔL* 3.5 measured below perception; their shipped pair
|
|
7
|
+
* (#F4F3ED warm paper vs #E4E4E2 neutral chrome) reads clearly at ΔL* 5.2
|
|
8
|
+
* because the warm→neutral TEMPERATURE step does perceptual work lightness
|
|
9
|
+
* alone doesn't. Hence the OR-form floor:
|
|
10
|
+
*
|
|
11
|
+
* ΔL* ≥ 6 — OR — a hue-temperature step (Δab ≥ 2) with ΔL* ≥ 5
|
|
12
|
+
*
|
|
13
|
+
* Both arms reject the failed 3.5 cut; the strict arm alone would have
|
|
14
|
+
* rejected the founder-certified shipped pair. The platform's theme-doc
|
|
15
|
+
* gate applies its own mirror of this rule server-side; this module is the
|
|
16
|
+
* lib's own honesty (derived defaults satisfy the floor BY CONSTRUCTION —
|
|
17
|
+
* pairs that land under it get the documented nudge apart, never a silent
|
|
18
|
+
* pass). Calibration may RAISE the floor; it does not lower it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A resolved tone pair for one mode: two backgrounds + their foregrounds. */
|
|
22
|
+
export interface TonePair {
|
|
23
|
+
/** Upper panel (app menus) background. */
|
|
24
|
+
upper: string;
|
|
25
|
+
/** Foreground on the upper tone. */
|
|
26
|
+
upperOn: string;
|
|
27
|
+
/** Lower panel (agent) background. */
|
|
28
|
+
lower: string;
|
|
29
|
+
/** Foreground on the lower tone. */
|
|
30
|
+
lowerOn: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The lib base defaults — the ggui#633 SHIPPED pair for light, and its
|
|
35
|
+
* temperature-step mirror for dark (warm umber menu vs neutral chrome,
|
|
36
|
+
* ΔL* 6.5 — the strict arm). Founder-certified "two-toned and
|
|
37
|
+
* well-distinguished" in light; the dark pair keeps the identical warm→
|
|
38
|
+
* neutral grammar so a mode flip changes brightness, not the layout's
|
|
39
|
+
* character. Both pairs assert the floor in this package's tests.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_TONES: Record<"light" | "dark", TonePair> = {
|
|
42
|
+
light: {
|
|
43
|
+
upper: "#F4F3ED",
|
|
44
|
+
upperOn: "#1F1E1B",
|
|
45
|
+
lower: "#E4E4E2",
|
|
46
|
+
lowerOn: "#1A1A1C",
|
|
47
|
+
},
|
|
48
|
+
dark: {
|
|
49
|
+
upper: "#302B24",
|
|
50
|
+
upperOn: "#ECE9E2",
|
|
51
|
+
lower: "#1E1E21",
|
|
52
|
+
lowerOn: "#E3E3E5",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** CIELAB (D65) from a `#rrggbb` hex. Throws on a malformed color. */
|
|
57
|
+
export function hexToLab(hex: string): { L: number; a: number; b: number } {
|
|
58
|
+
const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
59
|
+
if (m === null) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`agent-layout tones: "${hex}" is not a #rrggbb color — tone math needs resolvable hex (CSS keywords and var() belong to the stylesheet tier, not the derivation door).`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const h = m[1];
|
|
65
|
+
const chan = (i: number): number => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
66
|
+
const lin = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
67
|
+
const R = lin(chan(0));
|
|
68
|
+
const G = lin(chan(2));
|
|
69
|
+
const B = lin(chan(4));
|
|
70
|
+
const X = (0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047;
|
|
71
|
+
const Y = 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
|
72
|
+
const Z = (0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883;
|
|
73
|
+
const f = (t: number): number => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
|
74
|
+
return {
|
|
75
|
+
L: 116 * f(Y) - 16,
|
|
76
|
+
a: 500 * (f(X) - f(Y)),
|
|
77
|
+
b: 200 * (f(Y) - f(Z)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The perceptibility deltas between two backgrounds. */
|
|
82
|
+
export function toneDelta(upper: string, lower: string): { dL: number; dab: number } {
|
|
83
|
+
const A = hexToLab(upper);
|
|
84
|
+
const B = hexToLab(lower);
|
|
85
|
+
return { dL: Math.abs(A.L - B.L), dab: Math.hypot(A.a - B.a, A.b - B.b) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The OR-form floor (module header). */
|
|
89
|
+
export function meetsToneFloor(upper: string, lower: string): boolean {
|
|
90
|
+
const { dL, dab } = toneDelta(upper, lower);
|
|
91
|
+
return dL >= 6 || (dab >= 2 && dL >= 5);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reject-under-floor with the explanatory message (the brandAccent
|
|
96
|
+
* posture): overrides that fail perceptibility throw HERE, at wiring time,
|
|
97
|
+
* never render as an invisible seam.
|
|
98
|
+
*/
|
|
99
|
+
export function assertToneFloor(pair: TonePair): void {
|
|
100
|
+
if (meetsToneFloor(pair.upper, pair.lower)) return;
|
|
101
|
+
const { dL, dab } = toneDelta(pair.upper, pair.lower);
|
|
102
|
+
throw new Error(
|
|
103
|
+
`agent-layout tones: the upper/lower pair ${pair.upper}/${pair.lower} is below the perceptibility floor ` +
|
|
104
|
+
`(ΔL* ${dL.toFixed(1)}, Δab ${dab.toFixed(1)}; needs ΔL* ≥ 6, or a temperature step Δab ≥ 2 with ΔL* ≥ 5). ` +
|
|
105
|
+
`Two tones that measure alike defeat the category's defining behavior — pick a stronger pair.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Base-tier derivation (§2, platform's pick): tones DERIVE from the chat
|
|
111
|
+
* palette's surface/canvas pair, so an already-themed app gets coherent
|
|
112
|
+
* tones for free — theming chat themes the layout, no second authoring
|
|
113
|
+
* step. `surface` grounds the AGENT (lower) panel — it is the chat's own
|
|
114
|
+
* ground — and `canvas` grounds the MENU (upper) panel. Pairs that land
|
|
115
|
+
* under the floor get the documented nudge: the upper tone walks away from
|
|
116
|
+
* the lower along the lightness axis (toward white below L* 50, toward
|
|
117
|
+
* black above — always INTO the pair's existing contrast direction) until
|
|
118
|
+
* the strict arm passes. Deterministic, bounded, and asserted by tests.
|
|
119
|
+
*/
|
|
120
|
+
export function deriveTones(palette: {
|
|
121
|
+
surface: string;
|
|
122
|
+
canvas: string;
|
|
123
|
+
ink: string;
|
|
124
|
+
}): Pick<TonePair, "upper" | "lower"> & { nudged: boolean } {
|
|
125
|
+
const lower = palette.surface;
|
|
126
|
+
let upper = palette.canvas;
|
|
127
|
+
let nudged = false;
|
|
128
|
+
if (!meetsToneFloor(upper, lower)) {
|
|
129
|
+
nudged = true;
|
|
130
|
+
const lowerL = hexToLab(lower).L;
|
|
131
|
+
// Walk upper away from lower in sRGB mix steps until ΔL* ≥ 6. The mix
|
|
132
|
+
// target keeps the walk inside the pair's own contrast direction.
|
|
133
|
+
const towardWhite = hexToLab(upper).L >= lowerL;
|
|
134
|
+
for (let i = 1; i <= 20 && !(toneDelta(upper, lower).dL >= 6); i++) {
|
|
135
|
+
upper = mixHex(palette.canvas, towardWhite ? "#FFFFFF" : "#000000", i * 0.05);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { upper, lower, nudged };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Linear sRGB-space hex mix (`t` toward `target`). Exported for tests. */
|
|
142
|
+
export function mixHex(base: string, target: string, t: number): string {
|
|
143
|
+
const pb = /^#([0-9a-fA-F]{6})$/.exec(base.trim());
|
|
144
|
+
const pt = /^#([0-9a-fA-F]{6})$/.exec(target.trim());
|
|
145
|
+
if (pb === null || pt === null) throw new Error(`agent-layout tones: mixHex needs #rrggbb inputs`);
|
|
146
|
+
const out = [0, 2, 4]
|
|
147
|
+
.map((i) => {
|
|
148
|
+
const b = parseInt(pb[1].slice(i, i + 2), 16);
|
|
149
|
+
const g = parseInt(pt[1].slice(i, i + 2), 16);
|
|
150
|
+
return Math.round(b + (g - b) * Math.min(1, Math.max(0, t)))
|
|
151
|
+
.toString(16)
|
|
152
|
+
.padStart(2, "0");
|
|
153
|
+
})
|
|
154
|
+
.join("");
|
|
155
|
+
return `#${out.toUpperCase()}`;
|
|
156
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @guuey/agent-layout — the token sheet + structural rules (guuey#403 §2/§4).
|
|
3
|
+
*
|
|
4
|
+
* One import: `import "@guuey/agent-layout/styles.css"`. The tone values
|
|
5
|
+
* arrive as inline `--guuey-layout-*` custom properties from the Provider
|
|
6
|
+
* (base defaults = the ggui#633 founder-certified pair; overrides ride the
|
|
7
|
+
* §2 tier chain). This sheet carries structure, the follow transition, the
|
|
8
|
+
* <1024px drawer collapse, and the working-state treatment.
|
|
9
|
+
*
|
|
10
|
+
* `--guuey-layout-pane-tone` is LIB-WRITTEN state — apps read it, never
|
|
11
|
+
* set it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
.guuey-agent-layout {
|
|
15
|
+
height: 100%;
|
|
16
|
+
min-height: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.guuey-layout-shell {
|
|
20
|
+
display: grid;
|
|
21
|
+
grid-template-columns: minmax(240px, 300px) 1fr;
|
|
22
|
+
height: 100%;
|
|
23
|
+
min-height: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.guuey-layout-sidebar {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
min-height: 0;
|
|
30
|
+
min-width: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.guuey-layout-panel {
|
|
34
|
+
min-height: 0;
|
|
35
|
+
min-width: 0;
|
|
36
|
+
transition: background-color var(--guuey-layout-tone-transition) ease-out;
|
|
37
|
+
}
|
|
38
|
+
.guuey-layout-panel-app {
|
|
39
|
+
background: var(--guuey-layout-tone-upper);
|
|
40
|
+
color: var(--guuey-layout-tone-upper-on);
|
|
41
|
+
}
|
|
42
|
+
.guuey-layout-panel-agent {
|
|
43
|
+
flex: 1;
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
background: var(--guuey-layout-tone-lower);
|
|
47
|
+
color: var(--guuey-layout-tone-lower-on);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The follow: the pane's ground tracks the active panel (lib-written var),
|
|
51
|
+
* arriving on the founder-certified eased fade. */
|
|
52
|
+
.guuey-layout-pane {
|
|
53
|
+
min-width: 0;
|
|
54
|
+
min-height: 0;
|
|
55
|
+
overflow: auto;
|
|
56
|
+
background: var(--guuey-layout-pane-tone);
|
|
57
|
+
color: var(--guuey-layout-tone-upper-on);
|
|
58
|
+
transition: background-color var(--guuey-layout-tone-transition) ease-out;
|
|
59
|
+
}
|
|
60
|
+
.guuey-agent-layout[data-active-panel="agent"] .guuey-layout-pane {
|
|
61
|
+
color: var(--guuey-layout-tone-lower-on);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Founder (d): the working state — identity + pulse on the agent ground;
|
|
65
|
+
* prior page content never holds the room. */
|
|
66
|
+
.guuey-layout-working {
|
|
67
|
+
height: 100%;
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
gap: 16px;
|
|
73
|
+
}
|
|
74
|
+
.guuey-layout-working-identity {
|
|
75
|
+
opacity: 0.75;
|
|
76
|
+
}
|
|
77
|
+
.guuey-layout-working-pulse {
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
gap: 6px;
|
|
80
|
+
}
|
|
81
|
+
.guuey-layout-working-pulse span {
|
|
82
|
+
width: 8px;
|
|
83
|
+
height: 8px;
|
|
84
|
+
border-radius: 50%;
|
|
85
|
+
background: currentColor;
|
|
86
|
+
opacity: 0.4;
|
|
87
|
+
animation: guuey-layout-pulse 1.2s ease-in-out infinite;
|
|
88
|
+
}
|
|
89
|
+
.guuey-layout-working-pulse span:nth-child(2) {
|
|
90
|
+
animation-delay: 0.2s;
|
|
91
|
+
}
|
|
92
|
+
.guuey-layout-working-pulse span:nth-child(3) {
|
|
93
|
+
animation-delay: 0.4s;
|
|
94
|
+
}
|
|
95
|
+
@keyframes guuey-layout-pulse {
|
|
96
|
+
0%,
|
|
97
|
+
100% {
|
|
98
|
+
opacity: 0.25;
|
|
99
|
+
transform: translateY(0);
|
|
100
|
+
}
|
|
101
|
+
50% {
|
|
102
|
+
opacity: 0.9;
|
|
103
|
+
transform: translateY(-3px);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* The drawer toggle exists only at drawer widths (≥24px target — the
|
|
108
|
+
* ggui#633 a11y floor). */
|
|
109
|
+
.guuey-layout-drawer-toggle {
|
|
110
|
+
display: none;
|
|
111
|
+
}
|
|
112
|
+
.guuey-layout-sr-only {
|
|
113
|
+
position: absolute;
|
|
114
|
+
width: 1px;
|
|
115
|
+
height: 1px;
|
|
116
|
+
padding: 0;
|
|
117
|
+
margin: -1px;
|
|
118
|
+
overflow: hidden;
|
|
119
|
+
clip: rect(0 0 0 0);
|
|
120
|
+
white-space: nowrap;
|
|
121
|
+
border: 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── <1024px: the sidebar leaves the grid and becomes an overlay drawer;
|
|
125
|
+
* the follow is SUSPENDED — the pane holds the lower (agent) tone (a
|
|
126
|
+
* tone flip referencing a panel the user cannot see is noise, §4). ── */
|
|
127
|
+
@media (max-width: 1023.98px) {
|
|
128
|
+
.guuey-layout-shell {
|
|
129
|
+
grid-template-columns: 1fr;
|
|
130
|
+
}
|
|
131
|
+
.guuey-layout-sidebar {
|
|
132
|
+
position: fixed;
|
|
133
|
+
inset-block: 0;
|
|
134
|
+
inset-inline-start: 0;
|
|
135
|
+
width: min(85vw, 320px);
|
|
136
|
+
z-index: 20;
|
|
137
|
+
transform: translateX(-100%);
|
|
138
|
+
transition: transform var(--guuey-layout-tone-transition) ease-out;
|
|
139
|
+
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
|
|
140
|
+
}
|
|
141
|
+
.guuey-layout-shell[data-drawer-open="true"] .guuey-layout-sidebar {
|
|
142
|
+
transform: translateX(0);
|
|
143
|
+
}
|
|
144
|
+
.guuey-layout-drawer-toggle {
|
|
145
|
+
display: inline-flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: center;
|
|
148
|
+
position: fixed;
|
|
149
|
+
inset-block-start: 12px;
|
|
150
|
+
inset-inline-start: 12px;
|
|
151
|
+
z-index: 21;
|
|
152
|
+
min-width: 40px;
|
|
153
|
+
min-height: 40px;
|
|
154
|
+
border: none;
|
|
155
|
+
border-radius: 8px;
|
|
156
|
+
background: var(--guuey-layout-tone-upper);
|
|
157
|
+
color: var(--guuey-layout-tone-upper-on);
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
}
|
|
160
|
+
/* Follow suspended: fixed conversation tone, not a broken animation. */
|
|
161
|
+
.guuey-layout-pane {
|
|
162
|
+
background: var(--guuey-layout-tone-lower);
|
|
163
|
+
color: var(--guuey-layout-tone-lower-on);
|
|
164
|
+
transition: none;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Reduced motion: instant snap, no pulse travel (non-negotiable MUST). */
|
|
169
|
+
@media (prefers-reduced-motion: reduce) {
|
|
170
|
+
.guuey-layout-panel,
|
|
171
|
+
.guuey-layout-pane,
|
|
172
|
+
.guuey-layout-sidebar {
|
|
173
|
+
transition: none;
|
|
174
|
+
}
|
|
175
|
+
.guuey-layout-working-pulse span {
|
|
176
|
+
animation: none;
|
|
177
|
+
opacity: 0.6;
|
|
178
|
+
transform: none;
|
|
179
|
+
}
|
|
180
|
+
}
|