@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/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
+ }