diff --git a/NOTICE b/NOTICE index 393af171..7d83f8e7 100644 --- a/NOTICE +++ b/NOTICE @@ -41,20 +41,27 @@ Third-party trademarks ================================================================================ The application identifies supported Agents in a row using either a generic -OneAgent UI symbol or a separately audited image asset. Generic Lucide symbols -are used for Aider and OpenClaw, so no website favicon is copied into the -release: neither has a mark in lobe-icons, and the only SVG of OpenClaw's logo -in circulation was drawn by an unrelated project, which conveys no right to -redistribute it. The four retained image assets, for Codex, Claude Code, -OpenCode, and Kilo CLI, are listed with source, license, owner, and SHA-256 in +OneAgent UI symbol or a separately audited image asset. A generic Lucide symbol +is used for Aider, which has no mark in lobe-icons, so no website favicon is +copied into the release. The five retained image assets are listed with source, +license, owner, and SHA-256 in frontend/src/components/icons/asset-rights.json. +Four of those five -- for Codex, Claude Code, OpenCode, and Kilo CLI -- come +from lobe-icons and are redistributed unmodified. The fifth, for OpenClaw, is +not the vendor's own artwork: no official vector is published, so the mark is +the lobster drawn by the cc-switch project (MIT, Copyright (c) 2025 Jason +Young), and OneAgent has modified it. The red gradient, the coloured antenna +stroke, and the cyan eye highlights were removed so the mark is a single glyph +painted with fill="currentColor"; its geometry is unchanged. That modification +is recorded in the manifest entry, and the cc-switch license text travels with +it at frontend/src/components/icons/licenses/cc-switch/LICENSE. + The Agent marks described above are nominative use: the labels identify the referenced products and do not imply endorsement, affiliation or sponsorship. -The image assets keep their published geometry and are not re-drawn. Each is -distributed as a single-colour glyph painted with fill="currentColor", so it -takes the surrounding text colour by design rather than carrying a brand colour -that is being altered. +Every mark is distributed as a single-colour glyph painted with +fill="currentColor", so it takes the surrounding text colour rather than +carrying a brand colour. Sponsor marks are a separate case and are not nominative use. The PPIO and Novita logos under docs/assets/sponsors/ are reproduced with each owner's diff --git a/frontend/e2e/wails.spec.ts b/frontend/e2e/wails.spec.ts index 735a7722..3f1bd995 100644 --- a/frontend/e2e/wails.spec.ts +++ b/frontend/e2e/wails.spec.ts @@ -12,6 +12,51 @@ test("language selection switches to English and persists", async ({ page }) => await expect(page.getByRole("combobox", { name: "Language" })).toHaveValue("en"); }); +// The task centre is position: fixed at the viewport's lower-left corner, so it +// takes no space in the sidebar's flex column. When the sidebar did not reserve +// room for it, the rows margin-top: auto pushes to the bottom ended up +// underneath: still rendered, still visible, still in the accessibility tree, +// with every click landing on the overlay. The language picker was the one that +// lost, because the theme row cleared it by 2px. +// +// This has to be an e2e test. jsdom reports every rect as 0x0, so a unit test +// cannot see the collision, and asserting display or visibility would have +// passed throughout -- both were correct the whole time. +test("every sidebar control at the bottom is actually clickable", async ({ page }) => { + // Both sidebar widths: 204px above the 900px breakpoint, and the 72px icon + // rail below it, where the task centre and the selects change size. + for (const viewport of [{ width: 1180, height: 760 }, { width: 860, height: 600 }]) { + await page.setViewportSize(viewport); + await page.goto("/#/overview"); + const label = `${viewport.width}x${viewport.height}`; + + const covered = await page.evaluate(() => { + const selectors = [".theme-picker select", ".language-select-wide", ".language-select-compact", ".task-center-trigger"]; + const blocked: Array<{ selector: string; coveredBy: string }> = []; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (!element) continue; + const box = element.getBoundingClientRect(); + // The sidebar swaps the wide and compact selects per breakpoint; the + // hidden one of the pair is not a failure. + if (box.width === 0 || box.height === 0) continue; + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + if (hit !== element && !element.contains(hit)) { + blocked.push({ selector, coveredBy: hit ? `${hit.tagName}.${hit.className}` : "nothing" }); + } + } + return blocked; + }); + expect(covered, `controls covered at ${label}`).toEqual([]); + + // A real pointer click, not selectOption: the regression was that the + // element stayed reachable programmatically while being unreachable by + // pointer, so dispatching events directly would have passed. + await page.locator(".language-select-wide, .language-select-compact").locator("visible=true").click({ timeout: 2000 }); + await page.keyboard.press("Escape"); + } +}); + test("a machine with no OneAgent state opens onboarding from the landing route", async ({ page }) => { await page.goto("/#/"); await expect(page.getByRole("heading", { name: "选择命令行 Agent" })).toBeVisible(); diff --git a/frontend/src/components/icons/agents.test.tsx b/frontend/src/components/icons/agents.test.tsx index ca418367..64df46a3 100644 --- a/frontend/src/components/icons/agents.test.tsx +++ b/frontend/src/components/icons/agents.test.tsx @@ -28,7 +28,7 @@ describe("AgentIcon", () => { // set rather than one example is what makes an unregistered mark fail here: // shipping artwork without a source, licence and hash is the defect. const assetIds = AGENT_ICON_IDS.filter((id) => agentMarkKind(id) === "asset"); - expect(assetIds.sort()).toEqual(["claude-code", "codex", "kilo-cli", "opencode"]); + expect(assetIds.sort()).toEqual(["claude-code", "codex", "kilo-cli", "openclaw", "opencode"]); for (const id of assetIds) { const rights = agentMarkRights(id); expect(agentMarkKind(id)).toBe("asset"); @@ -100,12 +100,47 @@ describe("AgentIcon", () => { }); it("keeps the published geometry of every licensed mark", () => { - // The compliance note in agents.tsx states the marks are not re-drawn. - // The viewBox is the check: inlining must not rescale or crop the artwork. - for (const id of AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset")) { + // The compliance note in agents.tsx states the marks are not re-drawn. The + // viewBox is the check: inlining must not rescale or crop the artwork. + // + // Each mark is checked against its own source coordinate system rather than + // one shared value. The four vendor marks come from lobe-icons at 24x24; + // OpenClaw's is the cc-switch drawing at 120x120, and normalising it to 24 + // would be the re-drawing this test exists to prevent. The recolouring + // recorded in asset-rights.json does not touch geometry. + const PUBLISHED_VIEWBOX: Record = { + codex: "0 0 24 24", + opencode: "0 0 24 24", + "claude-code": "0 0 24 24", + "kilo-cli": "0 0 24 24", + openclaw: "0 0 120 120", + }; + const assetIds = AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset"); + // Guards the map itself: a new asset with no entry would otherwise be skipped + // rather than reported. + expect(Object.keys(PUBLISHED_VIEWBOX).sort()).toEqual([...assetIds].sort()); + for (const id of assetIds) { const { container } = render(); const svg = container.querySelector('[data-mark-kind="asset"] svg'); - expect(svg!.getAttribute("viewBox"), id).toBe("0 0 24 24"); + expect(svg!.getAttribute("viewBox"), id).toBe(PUBLISHED_VIEWBOX[id]); + } + }); + + it("records that the one modified asset was modified", () => { + // MIT lets a copy be changed, but the change has to be stated. OpenClaw's + // mark was recoloured from a red gradient to currentColor, so shipping it as + // if it were untouched vendor artwork is the defect this catches -- in both + // directions, since claiming an unmodified mark was modified is also wrong. + for (const id of AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset")) { + const rights = agentMarkRights(id)!; + if (id === "openclaw") { + expect(rights.modified, id).toBe(true); + expect(rights.modificationNote, id).toMatch(/currentColor/); + // Not the vendor's own artwork, so the owner must not read as OpenClaw's. + expect(rights.copyrightOwner, id).toMatch(/cc-switch/); + } else { + expect("modified" in rights, `${id} should not claim a modification`).toBe(false); + } } }); diff --git a/frontend/src/components/icons/agents.tsx b/frontend/src/components/icons/agents.tsx index f2313bb7..4efb1c3d 100644 --- a/frontend/src/components/icons/agents.tsx +++ b/frontend/src/components/icons/agents.tsx @@ -2,23 +2,27 @@ * Agent marks used to identify rows in OneAgent. * * Five image assets are shipped because their exact source, MIT license text, - * copyright owner, and SHA-256 are tracked in asset-rights.json. Agents whose - * published artwork does not have an auditable redistribution basis use generic - * Lucide marks instead; those marks identify a row without copying a vendor - * favicon into the release. + * copyright owner, and SHA-256 are tracked in asset-rights.json. An Agent with + * no such basis uses a generic Lucide mark instead, which identifies a row + * without copying a vendor favicon into the release. * * Trademark note: the labels identify which Agent a row refers to. The generic - * marks are OneAgent UI symbols, not vendor artwork. The licensed marks keep - * their published geometry unchanged; none is restyled or re-drawn. Each is - * distributed as a single-colour glyph painted with fill="currentColor", so it - * takes the surrounding text colour by design rather than carrying a brand - * colour we would be altering -- that is what makes them legible on both - * themes. + * marks are OneAgent UI symbols, not vendor artwork. Every mark is a + * single-colour glyph painted with fill="currentColor", so it takes the + * surrounding text colour rather than carrying a brand colour -- that is what + * makes the set legible on both themes. + * + * Four of the five are the vendors' own artwork, redistributed with their + * published geometry unchanged and not re-drawn. OpenClaw is the exception on + * both counts: no official vector exists, so the mark is the lobster the + * cc-switch project drew, and OneAgent recoloured it from a red gradient to a + * single currentColor glyph to match the set. Its geometry is still unchanged. + * asset-rights.json carries `modified: true` and the specifics, because MIT + * requires a modified copy to travel with its licence and state the change. */ import { Bot, GitBranch, - Waypoints, type LucideIcon } from "lucide-react"; @@ -28,9 +32,20 @@ import assetRightsManifest from "./asset-rights.json"; import claudeCodeMark from "./assets/claude-code.svg?raw"; import codexMark from "./assets/codex.svg?raw"; import kiloCliMark from "./assets/kilo-cli.svg?raw"; +import openclawMark from "./assets/openclaw.svg?raw"; import opencodeMark from "./assets/opencode.svg?raw"; -type AssetRights = (typeof assetRightsManifest.assets)[keyof typeof assetRightsManifest.assets]; +/** + * The union of every manifest entry, widened so the two fields that exist only + * on a modified asset are readable without narrowing at each use. They stay + * optional: `modified` present and true is the claim, and its absence is the + * claim that the artwork is untouched -- a distinction agents.test.tsx asserts + * in both directions. + */ +type AssetRights = (typeof assetRightsManifest.assets)[keyof typeof assetRightsManifest.assets] & { + modified?: boolean; + modificationNote?: string; +}; type AssetMark = { kind: "asset"; markup: string; source: string; rights: AssetRights }; type GenericMark = { kind: "generic"; Icon: LucideIcon; source: string }; type Mark = AssetMark | GenericMark; @@ -65,12 +80,18 @@ const MARKS: Record = { // Aider has no mark in lobe-icons, so it keeps a generic symbol rather than a // vendor favicon copied in without an auditable redistribution basis. aider: { kind: "generic", Icon: GitBranch, source: GENERIC_SOURCE }, - // OpenClaw's real logo is a lobster, and the only SVG version available is one - // CC Switch drew itself (see docs/internal/cc-switch-reference-notes.md) -- a - // third party's redrawing is not the vendor's artwork and gives OneAgent no - // right to redistribute it. lobe-icons has no OpenClaw mark either, so it takes - // a generic symbol until an official asset with a licence appears. - openclaw: { kind: "generic", Icon: Waypoints, source: GENERIC_SOURCE }, + // Unlike the four above, this mark is not the vendor's own artwork: it is the + // lobster the cc-switch project drew, MIT, and OneAgent recoloured it to a + // single currentColor glyph so it adapts to the theme like the rest of the set. + // asset-rights.json records both facts, because MIT requires a modified copy to + // carry its licence and state the change. lobe-icons has no OpenClaw mark, and + // no official vector exists to prefer instead. + openclaw: { + kind: "asset", + markup: openclawMark, + source: assetRightsManifest.assets.openclaw.source, + rights: assetRightsManifest.assets.openclaw, + }, }; /** One-line positioning shown on hover; never a restatement of the name. */ diff --git a/frontend/src/components/icons/asset-rights.json b/frontend/src/components/icons/asset-rights.json index 71c859fc..d0041c86 100644 --- a/frontend/src/components/icons/asset-rights.json +++ b/frontend/src/components/icons/asset-rights.json @@ -32,6 +32,16 @@ "licenseSource": "licenses/lobehub/LICENSE", "copyrightOwner": "LobeHub contributors", "sha256": "a2aef0e0bb992ebe768bfb8c3a3ab6ff2c2638448b09dd88db27877de1c07a22" + }, + "openclaw": { + "file": "assets/openclaw.svg", + "source": "https://github.com/farion1231/cc-switch/blob/main/src/icons/extracted/claw.svg", + "license": "MIT", + "licenseSource": "licenses/cc-switch/LICENSE", + "copyrightOwner": "Jason Young (cc-switch contributors)", + "sha256": "9eac78bcfa8e106ed3267293235281743e161b5accf2ff5c676700b64cfcdbbc", + "modified": true, + "modificationNote": "Recoloured to a single currentColor glyph for theme adaptation: the linearGradient (#ff4d4d to #991b1b), the antenna stroke colour and the cyan eye highlights were removed, and the eyes became a mask cut-out. Geometry is unchanged. Not an official OpenClaw mark; drawn by the cc-switch project." } } } diff --git a/frontend/src/components/icons/assets/openclaw.svg b/frontend/src/components/icons/assets/openclaw.svg new file mode 100644 index 00000000..29d5bd9b --- /dev/null +++ b/frontend/src/components/icons/assets/openclaw.svg @@ -0,0 +1 @@ +OpenClaw diff --git a/frontend/src/components/icons/licenses/cc-switch/LICENSE b/frontend/src/components/icons/licenses/cc-switch/LICENSE new file mode 100644 index 00000000..d84c4f73 --- /dev/null +++ b/frontend/src/components/icons/licenses/cc-switch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jason Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index ba4b14be..b1aba27d 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -13,9 +13,14 @@ background: var(--window-bg); } +/* The bottom padding clears the viewport-docked task centre. It is not spacing: + .task-center is position: fixed and contributes no height here, so without it + the theme and language rows -- pushed down by margin-top: auto -- sit behind + the overlay. They stay visible and focusable while every click lands on the + overlay instead, which reads as a control that vanished. */ .navigation-sidebar { min-height: 0; - padding: 22px 12px 16px; + padding: 22px 12px calc(16px + var(--task-center-reserve)); display: flex; flex-direction: column; border-right: 1px solid var(--border); @@ -123,13 +128,18 @@ flex: 1; } +/* background-color, not the background shorthand: the shorthand resets the arrow + image base.css sets on every select. Same below for the language picker and + the Provider picker. */ .theme-picker select { width: 82px; min-width: 0; - padding: 5px 6px; + padding: 5px 22px 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-control); - background: var(--window-bg); + background-color: var(--window-bg); + background-position: right 5px center; + background-size: 11px 11px; color: var(--text-primary); font: inherit; } @@ -141,10 +151,12 @@ .language-picker select { width: 82px; min-width: 0; - padding: 5px 6px; + padding: 5px 22px 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-control); - background: var(--window-bg); + background-color: var(--window-bg); + background-position: right 5px center; + background-size: 11px 11px; color: var(--text-primary); font: inherit; } @@ -637,11 +649,11 @@ .provider-picker select { width: 100%; min-height: 42px; - padding: 0 12px; + padding: 0 30px 0 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); color: var(--text-primary); - background: var(--window-bg); + background-color: var(--window-bg); } .provider-picker select:focus { @@ -1016,11 +1028,26 @@ .sidebar-link.is-active::before { left: -12px; } .language-picker { padding: 0; } .language-select-wide { display: none; } - .language-select-compact { display: block; width: 48px; padding-inline: 3px; font-size: 10px; } + .language-select-compact { display: block; } /* Centre what is left, now that the flex: 1 spacer spans are gone. */ .theme-picker { padding: 0; justify-content: center; } - .theme-picker select { width: 48px; padding-inline: 3px; font-size: 10px; } + + /* 48px of column has no room for both the value and the arrow, so the arrow + goes and the value keeps the width. Position and behaviour still identify + the control as a select, and its aria-label is unchanged. + + Both selectors are (0,1,1), matching `.language-picker select` in the base + layer. Written as `.language-picker .language-select-compact` rather than the + bare class because that base rule would otherwise win on specificity and + keep the wide padding, leaving the arrow's 22px gap with no arrow in it. */ + .language-picker .language-select-compact, + .theme-picker select { + width: 48px; + padding-inline: 3px; + font-size: 10px; + background-image: none; + } } @media (max-height: 760px) { diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index c5030668..ac794bbb 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -127,6 +127,39 @@ button:active:not(:disabled) { outline: none; } +/* Every select in the app, so the closed control matches the buttons and text + fields beside it instead of whatever the OS draws. Without this the pickers + rendered as native macOS controls here and as something else again on Windows + and Linux, while the rest of the UI stayed consistent. + + The open