diff --git a/tauri/README.md b/tauri/README.md index 692d39e..3f0687f 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -56,6 +56,12 @@ M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1 这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02,Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。 +### M1-02 ActionGroup(已完成) + +`choice` tool call 必须在 `payload.data.action_group` 中使用 `single-choice`、`multi-choice` 或 `button` 模式,声明 1~12 个具有唯一 `id` 的 action。协议层会拒绝未知模式、空列表、重复 id 和超长文本。可信 Renderer 使用原生 DOM 按钮而不解释远端 HTML:单选和按钮动作点击后立即提交,多选在用户显式点击“提交选择”后一次性提交;卡片随即显示“已提交,等待 Agent 确认”并禁用所有控件。过期 call 从一开始即不可操作。 + +提交仅写入 Interaction Kernel 的 `{ action_ids: [...] }` submission 快照;此阶段没有网络请求、outbox、`tool.result` 回传、刷新恢复或 Hermes Adapter 改动。它们分别留给后续 M1 任务。`npm test` 当前为 3 个文件 / 15 个测试通过,`npm run build` 通过。 + ### 当前结构债务(M0 的改造对象) `src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。 diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 5c62393..4125c50 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -94,7 +94,9 @@ function loginFailureMessage(reason: unknown): string { return message || "登录失败,请稍后重试。"; } -const rendererContext = new TrustedDOMRendererContext(messages, presence); +const rendererContext = new TrustedDOMRendererContext(messages, presence, (callID, submission) => { + return interactionKernel.submitToolCall(callID, new Date().toISOString(), submission).disposition === "accepted"; +}); const rendererRegistry = new RendererRegistry(); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item)); diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index adee268..e310f9b 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -40,6 +40,19 @@ export type DeliveryState = /** The only standard interaction requests admitted during Milestone 1. */ export type ToolCallKind = "choice" | "confirm" | "input"; +export type ActionGroupMode = "single-choice" | "multi-choice" | "button"; + +export type ActionGroupAction = { + id: string; + label: string; + description?: string; +}; + +export type ActionGroup = { + mode: ActionGroupMode; + actions: readonly ActionGroupAction[]; +}; + export type ToolCallRequest = { call_id: string; tool: ToolCallKind; @@ -47,6 +60,8 @@ export type ToolCallRequest = { prompt: string; expires_at?: string; data: JsonObject; + /** Present only for the trusted `choice` renderer introduced in M1-02. */ + action_group?: ActionGroup; }; export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired"; @@ -267,11 +282,31 @@ function validAppCallPayload(payload: JsonObject): boolean { const TOOL_KINDS = new Set(["choice", "confirm", "input"]); const TOOL_FINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]); +const ACTION_GROUP_MODES = new Set(["single-choice", "multi-choice", "button"]); function optionalTimestamp(value: unknown): string | undefined { return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined; } +function parseActionGroup(value: unknown): ActionGroup | undefined { + const candidate = object(value); + const mode = text(candidate?.mode) as ActionGroupMode; + if (!candidate || !ACTION_GROUP_MODES.has(mode) || !Array.isArray(candidate.actions) || candidate.actions.length < 1 || candidate.actions.length > 12) return undefined; + const seen = new Set(); + const actions: ActionGroupAction[] = []; + for (const value of candidate.actions) { + const action = object(value); + const id = action && identifier(action.id); + const label = action && text(action.label).trim(); + const description = action ? text(action.description).trim() : ""; + if (!id || !label || label.length > 200 || seen.has(id)) return undefined; + if (description.length > 500) return undefined; + seen.add(id); + actions.push({ id, label, ...(description ? { description } : {}) }); + } + return { mode, actions }; +} + function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { const callID = identifier(payload.call_id); const tool = text(payload.tool) as ToolCallKind; @@ -280,6 +315,8 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { if (payload.expires_at !== undefined && !expiresAt) return undefined; const data = payload.data === undefined ? {} : object(payload.data); if (!data) return undefined; + const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined; + if (tool === "choice" && !actionGroup) return undefined; return { call_id: callID, tool, @@ -287,6 +324,7 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { prompt: text(payload.prompt), ...(expiresAt ? { expires_at: expiresAt } : {}), data, + ...(actionGroup ? { action_group: actionGroup } : {}), }; } diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index c9b4260..3fc88b6 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -78,7 +78,7 @@ describe("InteractionKernel", () => { raw: envelope({ id: "evt_tool_call", type: "lineup.v1.tool.call", - payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: {} }, + payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: { action_group: { mode: "single-choice", actions: [{ id: "alpha", label: "Alpha" }] } } }, }), source: "sync", received_at: "2026-08-03T08:00:00.000Z", @@ -87,7 +87,7 @@ describe("InteractionKernel", () => { raw: envelope({ id: "evt_tool_call_duplicate_id", type: "lineup.v1.tool.call", - payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: {} }, + payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: { action_group: { mode: "button", actions: [{ id: "beta", label: "Beta" }] } } }, }), source: "live", }); @@ -126,4 +126,36 @@ describe("InteractionKernel", () => { expect(invalidCall.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]); expect(invalidResult.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]); }); + + it("accepts only a bounded, uniquely identified ActionGroup for choice calls", () => { + const kernel = new InteractionKernel(); + const accepted = kernel.ingest({ + raw: envelope({ + id: "evt_choice_group", + type: "lineup.v1.tool.call", + payload: { + call_id: "call_choice_group", + tool: "choice", + title: "Pick deployment targets", + prompt: "You may select multiple targets.", + data: { action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } }, + }, + }), + source: "sync", + }); + const duplicateActionID = kernel.ingest({ + raw: envelope({ + id: "evt_choice_invalid_group", + type: "lineup.v1.tool.call", + payload: { call_id: "call_bad_group", tool: "choice", title: "Bad", prompt: "Bad", data: { action_group: { mode: "multi-choice", actions: [{ id: "same", label: "One" }, { id: "same", label: "Two" }] } } }, + }), + source: "sync", + }); + + expect(accepted.items).toEqual([expect.objectContaining({ + kind: "tool-call", + call: expect.objectContaining({ action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } }), + })]); + expect(duplicateActionID.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]); + }); }); diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/interaction-kernel.ts index 2cae883..016b82d 100644 --- a/tauri/src/runtime/interaction-kernel.ts +++ b/tauri/src/runtime/interaction-kernel.ts @@ -70,8 +70,8 @@ export class InteractionKernel { }; } - public submitToolCall(callID: string, updatedAt: string) { - return this.toolCalls.submit(callID, updatedAt); + public submitToolCall(callID: string, updatedAt: string, submission: JsonObject = {}) { + return this.toolCalls.submit(callID, updatedAt, submission); } public completeToolCall(callID: string, status: ToolCallFinalStatus, result: JsonObject, updatedAt: string, error?: string) { diff --git a/tauri/src/runtime/tool-call-state.test.ts b/tauri/src/runtime/tool-call-state.test.ts index c992869..7694770 100644 --- a/tauri/src/runtime/tool-call-state.test.ts +++ b/tauri/src/runtime/tool-call-state.test.ts @@ -32,8 +32,8 @@ describe("ToolCallStateMachine", () => { expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:01:00.000Z")) .toMatchObject({ disposition: "invalid_transition", record: { status: "pending" } }); - expect(state.submit("call_state_001", "2026-08-03T08:02:00.000Z")) - .toMatchObject({ disposition: "accepted", record: { status: "submitted" } }); + expect(state.submit("call_state_001", "2026-08-03T08:02:00.000Z", { action_ids: ["alpha"] })) + .toMatchObject({ disposition: "accepted", record: { status: "submitted", submission: { action_ids: ["alpha"] } } }); expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:03:00.000Z")) .toMatchObject({ disposition: "accepted", record: { status: "completed", result: { selected: "alpha" } } }); expect(state.submit("call_state_001", "2026-08-03T08:04:00.000Z").disposition).toBe("invalid_transition"); diff --git a/tauri/src/runtime/tool-call-state.ts b/tauri/src/runtime/tool-call-state.ts index b7ff1ff..3bae31e 100644 --- a/tauri/src/runtime/tool-call-state.ts +++ b/tauri/src/runtime/tool-call-state.ts @@ -16,6 +16,7 @@ export type ToolCallRecord = { created_at: string; updated_at: string; result?: JsonObject; + submission?: JsonObject; error?: string; }; @@ -47,8 +48,11 @@ export class ToolCallStateMachine { return { disposition: "accepted", record: copy(record) }; } - public submit(callID: string, updatedAt: string): ToolCallTransition { - return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; }); + public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition { + return this.transition(callID, updatedAt, ["pending"], record => { + record.status = "submitted"; + record.submission = { ...submission }; + }); } public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition { @@ -112,5 +116,6 @@ function copy(record: ToolCallRecord): ToolCallRecord { ...record, request: { ...record.request, data: { ...record.request.data } }, ...(record.result ? { result: { ...record.result } } : {}), + ...(record.submission ? { submission: { ...record.submission } } : {}), }; } diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index 0e74900..697fdf1 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -1,10 +1,14 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; import type { + ActionGroup, ConversationItem, DeliveryState, + JsonObject, } from "../protocol"; +export type ToolActionSubmitter = (callID: string, submission: JsonObject) => boolean; + /** * Trusted presentation context for the first-party chat shell. It owns only * DOM nodes and display-local state; it deliberately has no Store, Transport, @@ -17,6 +21,7 @@ export class TrustedDOMRendererContext { public constructor( private readonly messages: HTMLElement, private readonly presence: HTMLElement, + private readonly submitToolAction?: ToolActionSubmitter, ) {} public reset(): void { @@ -53,7 +58,11 @@ export class TrustedDOMRendererContext { } public renderToolCall(item: Extract): void { - this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`); + if (item.call.tool === "choice" && item.call.action_group) { + this.renderActionGroup(item.call.call_id, item.call.title, item.call.prompt, item.call.action_group, item.call.expires_at); + return; + } + this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-03 接入。`); } public renderToolResult(item: Extract): void { @@ -131,6 +140,76 @@ export class TrustedDOMRendererContext { return row; } + private renderActionGroup(callID: string, title: string, prompt: string, group: ActionGroup, expiresAt?: string): void { + const card = document.createElement("article"); + card.className = "action-group-card"; + card.dataset.callId = callID; + const heading = document.createElement("strong"); + heading.textContent = title || "请选择"; + const detail = document.createElement("p"); + detail.textContent = prompt; + const actions = document.createElement("div"); + actions.className = `action-group-actions ${group.mode}`; + const status = document.createElement("small"); + status.className = "action-group-status"; + const expired = expiresAt !== undefined && Date.parse(expiresAt) <= Date.now(); + status.textContent = expired ? "该交互已过期" : "等待你的选择"; + const buttons: HTMLButtonElement[] = []; + const selected = new Set(); + const submit = (actionIDs: readonly string[]) => { + const accepted = this.submitToolAction?.(callID, { action_ids: [...actionIDs] }) ?? false; + if (!accepted) { + status.textContent = "该交互已结束或已提交"; + return; + } + card.dataset.status = "submitted"; + status.textContent = "已提交,等待 Agent 确认"; + for (const button of buttons) button.disabled = true; + }; + + for (const action of group.actions) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "action-group-action"; + button.textContent = action.label; + button.title = action.description ?? action.label; + button.disabled = expired; + button.addEventListener("click", () => { + if (group.mode !== "multi-choice") { + submit([action.id]); + return; + } + if (selected.has(action.id)) selected.delete(action.id); + else selected.add(action.id); + button.setAttribute("aria-pressed", String(selected.has(action.id))); + submitButton.disabled = selected.size === 0; + }); + buttons.push(button); + actions.append(button); + } + + let submitButton: HTMLButtonElement; + if (group.mode === "multi-choice") { + submitButton = document.createElement("button"); + submitButton.type = "button"; + submitButton.className = "action-group-submit"; + submitButton.textContent = "提交选择"; + submitButton.disabled = true; + submitButton.addEventListener("click", () => submit([...selected])); + buttons.push(submitButton); + } else { + // It is only referenced by the multi-choice click handler above. + submitButton = document.createElement("button"); + } + + card.append(heading, detail, actions); + if (group.mode === "multi-choice") card.append(submitButton); + card.append(status); + if (expired) card.dataset.status = "expired"; + this.messages.append(card); + this.scrollLatest(); + } + private showThinking(detail: string): void { this.thinking?.remove(); this.thinking = this.appendBubble("agent", detail); diff --git a/tauri/src/style.css b/tauri/src/style.css index 3abc1b2..424d375 100644 --- a/tauri/src/style.css +++ b/tauri/src/style.css @@ -1 +1 @@ -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled{opacity:.5}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status{color:#aac5b5}.action-group-card[data-status="submitted"]{border-color:#89c7a6}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}