From 89914b6409995abf73185f6fb929e86908b2cca9 Mon Sep 17 00:00:00 2001 From: Archit Sharma Date: Fri, 23 Oct 2026 22:21:43 +0500 Subject: [PATCH 1/3] Mobile: a top bar with a Menu, a map you can scroll past, and nothing wider than the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI only; no backend changes. - Phones (≤760px): the sidebar becomes a top bar with the mark, the project switcher and a Menu button. The nav opens below it as a full, labelled list (44px rows) and closes on navigation, Escape, or widening. The theme had fixed the rail at 212px, which hid every nav link on a phone. - Map: one finger scrolls the page and two fingers or +/- move the map (a finger was trapped on the canvas). The map takes 62vh, the panel below grows with its content, and tapping a line or tool scrolls its panel into view. - Events, Sessions, Tools and Models: the full-bleed table used the desktop gutter and stuck out 16px; it now uses the phone gutter. - Project switcher and account/notification menus span the screen with 44px rows instead of running off the edge. - Pages get a 16px gutter; the account email is hidden in the top bar. Co-Authored-By: Claude Opus 5.5 --- dashboard/app/(product)/app/layout.tsx | 2 + dashboard/app/af-theme.css | 62 +++++++++++++++++++ dashboard/components/product/MobileMenu.tsx | 54 ++++++++++++++++ .../components/product/agent/GuardrailMap.tsx | 29 ++++++++- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 dashboard/components/product/MobileMenu.tsx diff --git a/dashboard/app/(product)/app/layout.tsx b/dashboard/app/(product)/app/layout.tsx index 47f22a84..cca39072 100644 --- a/dashboard/app/(product)/app/layout.tsx +++ b/dashboard/app/(product)/app/layout.tsx @@ -8,6 +8,7 @@ import { AccountMenu } from "@/components/product/AccountMenu"; import { NotificationsBell } from "@/components/product/NotificationsBell"; import { CommandSearch } from "@/components/product/CommandSearch"; import { SideNav } from "@/components/product/SideNav"; +import { MobileMenu } from "@/components/product/MobileMenu"; import { AppChrome } from "@/components/product/AppChrome"; import { Crumbs } from "@/components/product/Crumbs"; import { WORKSPACE_NAV } from "@/lib/product/nav"; @@ -131,6 +132,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod guard your agents + }> {signedIn ? : } diff --git a/dashboard/app/af-theme.css b/dashboard/app/af-theme.css index 1adb3064..3813a34c 100644 --- a/dashboard/app/af-theme.css +++ b/dashboard/app/af-theme.css @@ -643,3 +643,65 @@ a.gm-add { align-self: flex-start; text-decoration: none; } .lib-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } .lib-foot { margin-top: auto; } .lib-default { border-color: color-mix(in srgb, var(--af-brand) 35%, var(--af-line)); } + +/* ── Phones (≤760px) ───────────────────────────────────────────────────────── + * The rail becomes a top bar: the mark, the project switcher and a Menu button + * (MobileMenu). The nav opens below it as a full, labelled list with 44px rows, + * rather than main's row of unlabelled icons. Pages get a 16px gutter. */ +.side-menu-btn { display: none; } +@media (max-width: 760px) { + nav.side { + width: 100%; flex-basis: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + gap: 6px; padding: 6px 12px; overflow: visible; + position: sticky; top: 0; z-index: 40; + border-right: 0; border-bottom: 1px solid var(--af-line); + background: var(--af-bg); backdrop-filter: none; -webkit-backdrop-filter: none; + } + nav.side .brand { border-bottom: 0; padding: 4px 2px; flex: none; } + nav.side .brand-text { display: none; } + nav.side .project-switch { flex: 1 1 auto; min-width: 0; margin: 0; } + nav.side .project-switch-btn { padding: 7px 10px; width: 100%; } + nav.side .project-switch-name small { display: none; } + nav.side .project-switch-menu { position: fixed; top: 56px; left: 12px; right: 12px; min-width: 0; max-width: none; width: auto; } + .side-menu-btn { + display: inline-flex; align-items: center; gap: 6px; flex: none; + min-height: 40px; padding: 0 12px; border: 1px solid var(--af-line-2); border-radius: 3px; + background: transparent; color: var(--af-fg); font: inherit; font-size: 13px; cursor: pointer; + } + /* The nav groups and the foot: hidden until Menu is opened, then a full-width list. */ + /* As specific as globals.css's `display: contents` rule for the same divs, plus .shell. */ + .shell nav.side > div:not(.brand):not(.theme-toggle):not(.project-switch) { display: none; order: 4; } + .shell nav.side[data-open] > div:not(.brand):not(.theme-toggle):not(.project-switch) { display: block; flex-basis: 100%; } + nav.side .brand { order: 1; } + nav.side .project-switch { order: 2; } + .side-menu-btn { order: 3; } + nav.side[data-open] { max-height: 100dvh; overflow-y: auto; padding-bottom: 12px; } + nav.side[data-open] .group { display: block; padding: 12px 4px 4px; border-top: 0; } + nav.side[data-open] a { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 10px 8px; white-space: normal; font-size: 15px; } + nav.side[data-open] a span { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; clip-path: none; white-space: normal; } + nav.side[data-open] .side-foot { display: flex; padding: 12px 4px 0; border-top: 1px solid var(--af-line); margin-top: 8px; } + + /* The top bar under it: crumbs on one line, the actions beside them; no email text. */ + .topbar { position: static; padding: 8px 16px; gap: 8px; flex-wrap: wrap; } + .topbar .account-summary { display: none; } + .shell main { padding: 20px 16px 48px; } +} +@media (max-width: 760px) { + /* The map: most of the screen, not a 600px box; the panel below grows with its content. */ + .gm { --gm-h: 62vh; } + .gm-canvas { min-height: 380px; } + .gm-side { height: auto; } + .gm-side > .gm-panel, .gm-side > .gm-list { max-height: none; } +} +@media (max-width: 760px) { + /* Full-bleed tables bleed into the phone gutter (16px), not the desktop one (32px); + wide ones scroll sideways inside themselves. */ + .obs-table { margin: 0 -16px; } + .obs-table th:first-child, .obs-table td:first-child { padding-left: 16px; } +} +@media (max-width: 760px) { .gm-side { scroll-margin-top: 64px; } } +@media (max-width: 760px) { + nav.side .project-switch-menu button, nav.side .project-switch-menu .project-switch-foot { min-height: 44px; } + .notif-body, .account-menu-body { position: fixed; top: auto; left: 12px; right: 12px; width: auto; max-width: none; max-height: 70dvh; overflow-y: auto; } + .account-menu-row { min-height: 44px; display: flex; align-items: center; } +} diff --git a/dashboard/components/product/MobileMenu.tsx b/dashboard/components/product/MobileMenu.tsx new file mode 100644 index 00000000..51ad8d89 --- /dev/null +++ b/dashboard/components/product/MobileMenu.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +/** + * The sidebar's "Menu" button, shown on phones only (see af-theme.css). On a narrow + * screen the sidebar is a top bar: the mark, the project switcher and this button; + * the nav itself opens below it as a full, labelled list. It closes when a page is + * chosen, on Escape, and when the screen widens back to the desktop rail. + */ +export function MobileMenu() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const pathname = usePathname(); + + useEffect(() => { + const nav = ref.current?.closest("nav.side"); + if (!nav) return; + if (open) nav.setAttribute("data-open", ""); + else nav.removeAttribute("data-open"); + }, [open]); + + useEffect(() => setOpen(false), [pathname]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + const wide = window.matchMedia("(min-width: 761px)"); + const onWide = () => wide.matches && setOpen(false); + window.addEventListener("keydown", onKey); + wide.addEventListener("change", onWide); + return () => { + window.removeEventListener("keydown", onKey); + wide.removeEventListener("change", onWide); + }; + }, [open]); + + return ( + + ); +} diff --git a/dashboard/components/product/agent/GuardrailMap.tsx b/dashboard/components/product/agent/GuardrailMap.tsx index b717e7be..c3fe0394 100644 --- a/dashboard/components/product/agent/GuardrailMap.tsx +++ b/dashboard/components/product/agent/GuardrailMap.tsx @@ -21,7 +21,7 @@ import Link from "next/link"; import { Logo } from "@/components/product/Logo"; import { useRouter } from "next/navigation"; import { Bot, Cpu, FileText, Plus, Search, User, Wrench, X, type LucideIcon } from "lucide-react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ModePill, Pill } from "@/components/kit"; import { GROUP_BY, @@ -1215,6 +1215,23 @@ function Legend() { * between them. Clicking a rail, a tool or an agent makes that agent the one the * panel is about; with nothing selected the panel is that agent's set-up guide. */ +/** + * A phone-width screen. There one finger must scroll the page, not drag the map + * (otherwise a visitor's finger is trapped on the canvas): the map pans and zooms + * with two fingers or its +/- buttons instead. + */ +function useNarrow(): boolean { + const [narrow, setNarrow] = useState(false); + useEffect(() => { + const q = window.matchMedia("(max-width: 760px)"); + const on = () => setNarrow(q.matches); + on(); + q.addEventListener("change", on); + return () => q.removeEventListener("change", on); + }, []); + return narrow; +} + export function GuardrailMap({ maps, focus, @@ -1229,6 +1246,11 @@ export function GuardrailMap({ const [active, setActive] = useState(focus); const [selected, setSelected] = useState(null); const [hovered, setHovered] = useState(null); + const narrow = useNarrow(); + // On a phone the panel sits below the map: bring it into view when a line or tool is tapped. + useEffect(() => { + if (narrow && selected) document.querySelector(".gm-side")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, [narrow, selected]); const [expanded, setExpanded] = useState>(new Set()); const [query, setQuery] = useState(""); const [by, setBy] = useState("impact"); @@ -1322,10 +1344,13 @@ export function GuardrailMap({ fitViewOptions={ maps.length > 1 ? { padding: 0.25, maxZoom: 1, nodes: laid.nodes.filter((n) => (n.id.startsWith(`${active}|`) || (n.id === "user" && laid.edges.some((e) => e.source === "user" && e.target === `${active}|agent`))) && !n.parentId).map((n) => ({ id: n.id })) } - : { padding: 0.12, maxZoom: 1 } + : { padding: narrow ? 0.04 : 0.12, maxZoom: 1 } } minZoom={0.15} maxZoom={1.5} + panOnDrag={!narrow} + zoomOnScroll={!narrow} + preventScrolling={!narrow} nodesDraggable={false} nodesConnectable={false} proOptions={{ hideAttribution: true }} From 0aede02455bb45552cbb7e4142d47b358d16c506 Mon Sep 17 00:00:00 2001 From: Archit Sharma Date: Sat, 24 Oct 2026 09:12:03 +0500 Subject: [PATCH 2/3] Mobile: fixes from using the dashboard on a phone, task by task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI only. Found by doing real tasks at 375px (switch project, change a line's guardrails, resolve an issue, read a run, test a message, apply a library, make a token, connect a project), not by measuring widths. - Map: its line pills (~25x8px) couldn't be tapped. On a phone the lines are also listed under the map as 52px buttons (name and rule count, plus Tools), each opening the same panel; the legend, which explains the pills, is hidden. - Tables: on a phone every table with a header becomes stacked rows. Short cells (time, severity, outcome) sit as chips, the row's title in full, the rest two to a line under their column's name (TableLabels copies each header onto its cells, after hydration). Before, Outcome and Agent were cut off at the edge and panel tables showed only their first column. - Key/value tables (issue evidence) put the key above the value and wrap long ids. - Issue page: a fixed-width field pushed 'Not a problem' and 'Accept' off the card; list-row actions now drop under their text and fields fill the line. Page titles are 26px on a phone. - New project: the empty placeholder cards are hidden so 'Connect a new project' is the first thing on screen. - Sideways tab rows fade at the edge; library descriptions show in full; a check's remove button is finger-sized; the ⌘K hint is hidden. Co-Authored-By: Claude Opus 5.5 --- dashboard/app/(product)/app/layout.tsx | 2 + dashboard/app/af-theme.css | 72 +++++++++++++++++++ dashboard/components/product/TableLabels.tsx | 55 ++++++++++++++ .../components/product/agent/GuardrailMap.tsx | 39 ++++++++-- 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 dashboard/components/product/TableLabels.tsx diff --git a/dashboard/app/(product)/app/layout.tsx b/dashboard/app/(product)/app/layout.tsx index cca39072..b8e0268c 100644 --- a/dashboard/app/(product)/app/layout.tsx +++ b/dashboard/app/(product)/app/layout.tsx @@ -9,6 +9,7 @@ import { NotificationsBell } from "@/components/product/NotificationsBell"; import { CommandSearch } from "@/components/product/CommandSearch"; import { SideNav } from "@/components/product/SideNav"; import { MobileMenu } from "@/components/product/MobileMenu"; +import { TableLabels } from "@/components/product/TableLabels"; import { AppChrome } from "@/components/product/AppChrome"; import { Crumbs } from "@/components/product/Crumbs"; import { WORKSPACE_NAV } from "@/lib/product/nav"; @@ -133,6 +134,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod + }> {signedIn ? : } diff --git a/dashboard/app/af-theme.css b/dashboard/app/af-theme.css index 3813a34c..e6ed80ed 100644 --- a/dashboard/app/af-theme.css +++ b/dashboard/app/af-theme.css @@ -705,3 +705,75 @@ a.gm-add { align-self: flex-start; text-decoration: none; } .notif-body, .account-menu-body { position: fixed; top: auto; left: 12px; right: 12px; width: auto; max-width: none; max-height: 70dvh; overflow-y: auto; } .account-menu-row { min-height: 44px; display: flex; align-items: center; } } +/* The map's lines as tappable buttons, phones only (LineList). */ +.gm-lines { display: flex; flex-direction: column; gap: 8px; padding: 14px; border: 1px solid var(--af-line); border-radius: var(--af-r-card); background: var(--af-surface); } +.gm-lines-head { margin: 0; font-size: 13px; color: var(--af-fg-2); } +.gm-lines-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.gm-line-btn { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--af-line-2); border-radius: 8px; background: var(--af-bg); color: var(--af-fg); font: inherit; font-size: 14px; font-weight: 500; text-align: left; cursor: pointer; } +.gm-line-btn small { font-size: 12px; font-weight: 400; color: var(--af-muted); } +.gm-line-btn:active { background: var(--af-subtle); } +@media (max-width: 760px) { + /* The legend explains the map's pills, which a phone uses through the line list instead. */ + .gm-bar .gm-legend { display: none; } + .gm-bar:not(:has(.gm-bar-end)) { display: none; } +} +@media (max-width: 760px) { + /* Tables become stacked rows (TableLabels names each cell): short cells as a line of + chips, the row's title in full, then each other cell under its column's name. */ + .shell main table:has(> thead), .shell main table:has(> thead) tbody { display: block; width: 100%; min-width: 0; } + .shell main table:has(> thead) thead { display: none; } + .shell main table:has(> thead) tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; border-bottom: 1px solid var(--af-line); } + .shell main table:has(> thead) tr:last-child { border-bottom: 0; } + .shell main table:has(> thead) td { display: block; width: auto; min-width: 0; height: auto; padding: 0; border: 0; white-space: normal; text-align: left; overflow-wrap: anywhere; } + .shell main table:has(> thead) td:empty { display: none; } + .shell main table:has(> thead) td.tight { order: 0; } + .shell main table:has(> thead) td:not(.tight) { order: 1; flex-basis: 100%; } + .shell main table:has(> thead) td[data-primary] { font-size: 14px; } + /* Other wide cells: two to a line, each under its column's name. */ + .shell main table:has(> thead) td:not(.tight):not([data-primary])[data-label] { flex: 1 1 calc(50% - 10px); } + .shell main table:has(> thead) td:not(.tight):not([data-primary])[data-label]::before { + content: attr(data-label); display: block; margin-bottom: 1px; font-size: 11px; font-weight: 500; color: var(--af-muted); + } + .shell main table:has(> thead) tbody tr:hover td { background: transparent; } +} +@media (max-width: 760px) { + /* Page titles: big enough to be the title, not half the first screen. */ + :where(.shell) h1 { font-size: 26px; line-height: 32px; } + /* Fields with a fixed desktop width (inline style) fill the line; their button wraps under. */ + .shell main input.k-input, .shell main textarea.k-input, .shell main select.k-input { max-width: 100%; } + .shell main input.k-input[style*="width"], .shell main .k-input[style*="width"] { width: 100% !important; flex: 1 1 100%; } +} +@media (max-width: 760px) { + /* List rows with actions on the right (k-list): the actions drop under the text. */ + .shell main .k-list > li { flex-wrap: wrap; } + .shell main .k-list > li > .k-list-end { flex: 1 1 100%; justify-content: flex-start !important; } +} +@media (max-width: 760px) { + /* Key/value tables (two cells a row): the key above its value, long ids wrapped. */ + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) > td { display: block; width: auto; max-width: none !important; white-space: normal !important; overflow-wrap: anywhere; } + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) > td:first-child { padding-bottom: 0; border-bottom: 0; } +} +@media (max-width: 760px) { + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child), + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child) > tbody, + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) { display: block; width: 100%; min-width: 0; } + /* globals.css gives tables in a panel a 900px floor so wide ones scroll; a key/value list stacks instead. */ + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child) { min-width: 0; } +} +@media (max-width: 760px) { + /* New project: the connection card is the point; the empty placeholders go. */ + .np-cards { display: none; } + /* Tab rows that scroll sideways fade at the edge, so a cut-off tab reads as "more". */ + .shell main .k-tabs { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); padding-right: 24px; } + /* Library descriptions in full: the clamp hid how a library starts (watching first). */ + .lib-card p { display: block; -webkit-line-clamp: unset; overflow: visible; } + /* A check's remove button: a finger-sized target, not an 11px icon. */ + .gm-rule-checks .gm-chip button { min-width: 32px; min-height: 32px; margin: -8px -8px -8px 0; display: inline-flex; align-items: center; justify-content: center; } + /* No keyboard shortcut on a phone. */ + .topbar .cmdk-kbd { display: none; } +} +@media (max-width: 760px) { + /* Stacked, the Observe tables no longer need to bleed past the gutter or scroll. */ + .obs-table { margin: 0; overflow-x: visible; } + .obs-table td { height: auto; white-space: normal; } +} diff --git a/dashboard/components/product/TableLabels.tsx b/dashboard/components/product/TableLabels.tsx new file mode 100644 index 00000000..7c5c0057 --- /dev/null +++ b/dashboard/components/product/TableLabels.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +/** + * Gives every table cell its column's name (`data-label`) and marks each row's + * first wide cell as its title (`data-primary`). On a phone af-theme.css turns those + * tables into stacked rows: short cells (`.tight`: time, severity, outcome) as a line + * of chips, the title in full, and every other cell under its column's name. Without + * the names a stacked "3" or "2m ago" means nothing. Desktop ignores both attributes. + */ +export function TableLabels() { + const pathname = usePathname(); + useEffect(() => { + const label = () => { + for (const table of document.querySelectorAll("main table")) { + const names = [...table.querySelectorAll("thead th")].map((th) => th.textContent?.trim() ?? ""); + for (const row of table.querySelectorAll("tbody tr")) { + let primary = false; + [...row.cells].forEach((cell, i) => { + if (names[i] && cell.dataset.label !== names[i]) cell.dataset.label = names[i]; + if (!primary && !cell.classList.contains("tight") && cell.colSpan === 1) { + cell.dataset.primary = ""; + primary = true; + } + }); + } + } + }; + // Not before React has hydrated the server's HTML: an attribute added first is a + // hydration mismatch. So wait for the page to load, then a beat more. + let seen: MutationObserver | null = null; + let wait = 0; + const start = () => { + label(); + const main = document.querySelector("main"); + if (!main) return; + // Tables arrive late (streamed, filtered, paged): label them as they do. + seen = new MutationObserver(label); + seen.observe(main, { childList: true, subtree: true }); + }; + const later = () => { + wait = window.setTimeout(start, 400); + }; + if (document.readyState === "complete") later(); + else window.addEventListener("load", later, { once: true }); + return () => { + window.removeEventListener("load", later); + window.clearTimeout(wait); + seen?.disconnect(); + }; + }, [pathname]); + return null; +} diff --git a/dashboard/components/product/agent/GuardrailMap.tsx b/dashboard/components/product/agent/GuardrailMap.tsx index c3fe0394..350b8213 100644 --- a/dashboard/components/product/agent/GuardrailMap.tsx +++ b/dashboard/components/product/agent/GuardrailMap.tsx @@ -1209,12 +1209,36 @@ function Legend() { ); } +/** The lines drawn on the map, in reading order: the ones a phone lists as buttons. */ +const LINE_ORDER = ["input", "output", "prompt", "response", "tool_call", "tool_result", "context"]; + /** - * One project's guardrails, drawn where they act. The user outside; inside the - * project's box, each agent with its LLM, tools and documents; rails on the lines - * between them. Clicking a rail, a tool or an agent makes that agent the one the - * panel is about; with nothing selected the panel is that agent's set-up guide. + * On a phone the map's pills are too small to tap, so its lines are also listed here + * as full-size buttons: each opens the same panel as tapping the line on the map. */ +function LineList({ map, onSelect }: { map: AgentMap; onSelect: (next: Selected) => void }) { + const lines = LINE_ORDER.map((key) => map.stages.find((s) => s.key === key)).filter((s): s is Stage => Boolean(s)); + return ( +
+

Tap a line to see and change its guardrails

+
+ {lines.map((s) => ( + + ))} + {map.tools.length > 0 && ( + + )} +
+
+ ); +} + /** * A phone-width screen. There one finger must scroll the page, not drag the map * (otherwise a visitor's finger is trapped on the canvas): the map pans and zooms @@ -1232,6 +1256,12 @@ function useNarrow(): boolean { return narrow; } +/** + * One project's guardrails, drawn where they act. The user outside; inside the + * project's box, each agent with its LLM, tools and documents; rails on the lines + * between them. Clicking a rail, a tool or an agent makes that agent the one the + * panel is about; with nothing selected the panel is that agent's set-up guide. + */ export function GuardrailMap({ maps, focus, @@ -1371,6 +1401,7 @@ export function GuardrailMap({
{agentSwitch} + {narrow && !selected && }
From 4b35f179714e39b76c28468ae353cd633e1cc390 Mon Sep 17 00:00:00 2001 From: Archit Sharma Date: Sat, 24 Oct 2026 09:46:32 +0500 Subject: [PATCH 3/3] Mobile: fixes from a second pass on a phone UI only. Checked with real phone emulation (375px, touch) through Chrome's DevTools protocol, page by page and task by task. - Stacked tables had lost to globals.css's '.k-table .tight { width: 1% }' once the selector was widened to every table with a header: the time squeezed to 16px and sat on top of the outcome chip on Runs. The rules are lifted with :root and the reason is noted. - Tables without a header row (a run's rules, the Protect wizard's protections) stack too: the name and its description in full first, then the chips and controls below, instead of a one-word-wide name column. - Attack test: Run and + are 40px, and the attack library opens across the panel. 31 app pages at 375px: none wider than the screen. Public pages (home, pricing) were already responsive. Co-Authored-By: Claude Opus 5.5 --- dashboard/app/af-theme.css | 40 +++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/dashboard/app/af-theme.css b/dashboard/app/af-theme.css index e6ed80ed..bc0ca51f 100644 --- a/dashboard/app/af-theme.css +++ b/dashboard/app/af-theme.css @@ -718,23 +718,25 @@ a.gm-add { align-self: flex-start; text-decoration: none; } .gm-bar:not(:has(.gm-bar-end)) { display: none; } } @media (max-width: 760px) { - /* Tables become stacked rows (TableLabels names each cell): short cells as a line of - chips, the row's title in full, then each other cell under its column's name. */ - .shell main table:has(> thead), .shell main table:has(> thead) tbody { display: block; width: 100%; min-width: 0; } - .shell main table:has(> thead) thead { display: none; } - .shell main table:has(> thead) tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; border-bottom: 1px solid var(--af-line); } - .shell main table:has(> thead) tr:last-child { border-bottom: 0; } - .shell main table:has(> thead) td { display: block; width: auto; min-width: 0; height: auto; padding: 0; border: 0; white-space: normal; text-align: left; overflow-wrap: anywhere; } - .shell main table:has(> thead) td:empty { display: none; } - .shell main table:has(> thead) td.tight { order: 0; } - .shell main table:has(> thead) td:not(.tight) { order: 1; flex-basis: 100%; } - .shell main table:has(> thead) td[data-primary] { font-size: 14px; } + /* Tables become stacked rows (TableLabels names each cell). `:root` lifts these above + globals.css's `.k-table .tight { width: 1% }`, which otherwise squeezes the chips. + Short cells as a line of chips, the row's title in full, then each other cell + under its column's name. */ + :root .shell main :is(table:has(> thead), table.k-table), :root .shell main :is(table:has(> thead), table.k-table) tbody { display: block; width: 100%; min-width: 0; } + :root .shell main :is(table:has(> thead), table.k-table) thead { display: none; } + :root .shell main :is(table:has(> thead), table.k-table) tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; border-bottom: 1px solid var(--af-line); } + :root .shell main :is(table:has(> thead), table.k-table) tr:last-child { border-bottom: 0; } + :root .shell main :is(table:has(> thead), table.k-table) td { display: block; width: auto; min-width: 0; height: auto; padding: 0; border: 0; white-space: normal; text-align: left; overflow-wrap: anywhere; } + :root .shell main :is(table:has(> thead), table.k-table) td:empty { display: none; } + :root .shell main :is(table:has(> thead), table.k-table) td.tight { order: 0; } + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight) { order: 1; flex-basis: 100%; } + :root .shell main :is(table:has(> thead), table.k-table) td[data-primary] { font-size: 14px; } /* Other wide cells: two to a line, each under its column's name. */ - .shell main table:has(> thead) td:not(.tight):not([data-primary])[data-label] { flex: 1 1 calc(50% - 10px); } - .shell main table:has(> thead) td:not(.tight):not([data-primary])[data-label]::before { + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight):not([data-primary])[data-label] { flex: 1 1 calc(50% - 10px); } + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight):not([data-primary])[data-label]::before { content: attr(data-label); display: block; margin-bottom: 1px; font-size: 11px; font-weight: 500; color: var(--af-muted); } - .shell main table:has(> thead) tbody tr:hover td { background: transparent; } + :root .shell main :is(table:has(> thead), table.k-table) tbody tr:hover td { background: transparent; } } @media (max-width: 760px) { /* Page titles: big enough to be the title, not half the first screen. */ @@ -777,3 +779,13 @@ a.gm-add { align-self: flex-start; text-decoration: none; } .obs-table { margin: 0; overflow-x: visible; } .obs-table td { height: auto; white-space: normal; } } +@media (max-width: 760px) { + /* A table without a header row (a list of named things with controls): keep its own + order, the name in full first, its chips and controls on the line below. */ + :root .shell main table.k-table:not(:has(> thead)) td.tight { order: 2; } +} +@media (max-width: 760px) { + /* Attack test: Run and + at finger size; the library opens across the panel. */ + .at-icon { width: 40px; height: 40px; } + .at-lib { left: 0; right: 0; width: auto; max-width: none; } +}