diff --git a/packages/app/CONTEXT.md b/packages/app/CONTEXT.md index 53a5f5e8f..fba70f8f1 100644 --- a/packages/app/CONTEXT.md +++ b/packages/app/CONTEXT.md @@ -76,6 +76,88 @@ Connections, solver/model Defaults, and Settings. These are set-and-forget surfa not places a researcher browses. _Avoid_: Settings menu (the zone is broader), footer +### Brand + +**Brand sheet**: +The one token file, canonical for every design-system value — hue, corner, unit, type, hairline, focus, motion — and holding nothing else. A value that disagrees with it is wrong; a value not in it is not part of the system; a rule that styles a surface is a **Skin**. See DESIGN.md. +_Avoid_: theme (that is the **Theme palette**), stylesheet, design tokens file, polish + +**Skin**: +A rule that applies the **Brand sheet** to a named surface — the composer, the send button, the solver banner. Skins live in their own stylesheet, are gated exactly like any component, and never define a token or force themselves over the sheet. See DESIGN.md, Law 9. +_Avoid_: polish, override, theme tweak, component style (as something exempt) + +**Design law**: +A rule of the brand that no token can express — how the accent may be paired, what a border is, what may move. Laws live in DESIGN.md with their reasons and are enforced by the **Design gate**. +_Avoid_: guideline, style rule, convention, best practice + +**Design gate**: +The checks that fail a build when code disagrees with the **Brand sheet** or a **Design law**: the static gate reads the source for literals, the rendered gate reads the running app in both schemes. A law without a gate is not yet a law. +_Avoid_: lint, style check, visual test + +**Brand accent**: +The one yellow the interface carries, and it carries it for one reason: it is the **Call to action**. Paired only as black-on-yellow or yellow-on-dark, never as a yellow foreground on a light surface, and never for selection, emphasis, or status. See DESIGN.md, Law 1. +_Avoid_: highlight colour, primary colour, gold, "the yellow" (unqualified), second accent, selected colour + +**Call to action**: +The one control on a surface that prompts the researcher's immediate next action — send, connect, approve, begin — and the only thing that carries the **Brand accent**. A surface has at most one; a state is never one; yellow prompts and never points. See DESIGN.md, Law 1. +_Avoid_: primary button (generic), accent button, yellow button, highlight, active state + +**Semantic state**: +The colours that mean something — success, warning, danger, and the session status dots — and the only chromatic hues besides the **Brand accent**. Never decorative, never an accent, never an edge; the meaning is always also carried by text or an icon. See DESIGN.md, Laws 1 and 3. +_Avoid_: status colours (as a design category), alert colours, accent (for these), error border + +**Data-viz palette**: +The defined set of categorical colours the context tree and brain engines paint from — one hue per category, chosen under the accent law. The canvas is inside the system: its live marker is a pointer and is neutral. See DESIGN.md, Law 1. +_Avoid_: chart theme, secondary palette, accent set, exemption + +**Principle**: +A behavioural rule of the interface — reduce choices, show progress, end flows well — that a designer checks in review rather than a gate checks in a build. Principles guide decisions; **Design laws** bind them. See DESIGN.md, Principles. +_Avoid_: law (for these), heuristic (as the canonical word), guideline, rule of thumb + +**Theme palette**: +The surfaces, text, borders, and **Semantic state** colours the theme owns per scheme. The **Brand sheet** consumes it and never redefines it; the palette's accent is the **Brand accent** by reference. See DESIGN.md, Law 8. +_Avoid_: theme (as the whole design system), skin, colour scheme (that is light / dark), palette (unqualified) + +**Hairline**: +The only edge the interface draws: one width, solid, in a neutral tone — or the call-to-action edge, ink on the armed fill and the scheme's own ink or cream on the idle outline. A state is never an edge; a floating surface is a hairline-bordered surface on its own ground layer; nothing casts a shadow. See DESIGN.md, Laws 2 and 3. +_Avoid_: emphasis border, ring, inset shadow, bevel, elevation, divider (as a distinct thing), error border + +**Corner**: +The one rounding every control and surface shares; dots and round pills are circles. Nothing is rounded more or less than anything else, and hierarchy never comes from corner size. See DESIGN.md, Law 4. +_Avoid_: radius scale, size-tiered corners, pill (for a non-round control), soft corner + +**Unit**: +The one spacing measure every padding, margin, and gap is a multiple of. A half-unit exists only as an inset beside a **Hairline**. See DESIGN.md, Law 10. +_Avoid_: spacing scale, half-step, tight padding, off-grid (say "off the unit") + +**UI type**: +The size at which the interface describes itself — labels, controls, chrome, panels. Everything the interface says about itself is UI type; what the agent and researcher write is **Content type**. See DESIGN.md, Law 5. +_Avoid_: body text, small text, chrome text (as a size), base size + +**Content type**: +The size at which what the agent and the researcher write — message prose and tool output — renders, one step above **UI type**. See DESIGN.md, Law 5. +_Avoid_: body text (ambiguous between the two), base size, base text, prose size + +**Focus ring**: +The thin outline, set off from the control, that marks keyboard focus — ink on light, cream on dark, never the **Brand accent**, because yellow prompts and never points. Present on every interactive element and never removed. See DESIGN.md, Laws 1 and 7. +_Avoid_: focus outline (generic), focus state (broader), blue ring, yellow ring, glow + +**Arrival**: +The first of three movements the interface makes: a block entering the timeline, or a card swapping into the dock, rises and un-blurs into place; nothing moves on hover, press, or state change. Under reduced motion an arrival is a fade. See DESIGN.md, Law 6. +_Avoid_: animation (generic), hover lift, micro-interaction, bounce, transition (for the movement itself) + +**Glide**: +The second movement: a marker that follows attention — the tour spotlight — moving between positions, position only. Under reduced motion a glide is a jump-cut with a fade. See DESIGN.md, Law 6. +_Avoid_: slide, tween, animated highlight, teleport (that is the reduced-motion fallback, not the behaviour) + +**Working**: +The third and last movement: an indeterminate indicator — a loader, a pulse, a shimmer — running continuously within its own bounds, never travelling or scaling. Under reduced motion it holds a still frame. See DESIGN.md, Law 6. +_Avoid_: spinner (as the concept), loading animation, busy state (broader), determinate progress (shares the marker; it is not a movement) + +**Quiet control**: +A control that is visually muted until hovered or focused but always exists — in the tab order, named, and fully visible on focus. Hover changes its emphasis, never its presence. See DESIGN.md, Law 7. +_Avoid_: hover-reveal (as the concept), hidden control, hover-only action, ghost button (that is a variant, not a behaviour) + ### Ambient surfaces **Live-solve indicator**: @@ -114,6 +196,18 @@ only surfaces its state), setup wall These were real surfaces before the chat-first redesign and are intentionally gone. A reader meeting them in older code or notes should map them forward: +- **Exemptions** (files and surfaces outside the design laws — logos, the poster, the + engines, the diff engine, the sheet itself) → gone under DESIGN.md. Every surface is + inside the system; what was exempt is now defined, or is a delta. +- **State edges** (a red, amber, or green border carrying success / warning / danger) → gone + under DESIGN.md Law 3. A state is shown by fill, text, or icon; the edge stays a **Hairline**. +- **Yellow selection** (the soft-yellow fill and yellow edge on a selected or active row) → gone + under DESIGN.md Law 1. Selection is neutral — a ground layer and a strong hairline; the + **Brand accent** marks only the **Call to action**. +- **Elevation / float shadow** (the named float shadow, the theme's elevation tiers, the + utility shadows) → gone under DESIGN.md Law 2. A dialog, popover, palette, or toast is a + **Hairline**-bordered surface on its own ground layer; nothing in the interface casts a + shadow. - **Home / Dashboard** (the aggregate widget page) → dissolved. Its widgets became **Rail surfaces**, **Account zone** entries, in-Chat context, or first-run affordances. - **Home top chrome strip** (the Home page's brand mark + defaults capsule + projects diff --git a/packages/app/docs/adr/0003-context-tree-top-panel.md b/packages/app/docs/adr/0003-context-tree-top-panel.md index bd6f3b12b..2cbb4387a 100644 --- a/packages/app/docs/adr/0003-context-tree-top-panel.md +++ b/packages/app/docs/adr/0003-context-tree-top-panel.md @@ -1,5 +1,8 @@ # 0003 — The knowledge graph moves to a top panel as the agent's context tree; the vault gets a first-class surface +Superseded in part by the design system (`packages/app/src/design-system/DESIGN.md`, Law 1; 2026-09-08), consequence 1 only: the engines are not exempt from the accent law and no yellow marks the live position — they paint from the data-viz palette and the live marker is neutral. + + - Status: Accepted - Date: 2026-07-27 - Deciders: Kate Bonner (Head of Product), on Aaron Trowbridge's design feedback (2026-07-26) diff --git a/packages/app/e2e/regression/design-system-conformance.spec.ts b/packages/app/e2e/regression/design-system-conformance.spec.ts new file mode 100644 index 000000000..7196faa83 --- /dev/null +++ b/packages/app/e2e/regression/design-system-conformance.spec.ts @@ -0,0 +1,339 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// Design-system conformance — the amicode-design-system skill, executed. +// +// The static gate (script/check-design-tokens.mjs) proves components carry no +// literals. This spec proves what actually RENDERS conforms, in both themes: +// the tokens are read live from design-system/tokens.css at runtime, so the numbers +// here can never go stale — if the brand sheet changes, the assertions follow. +// +// Rules (skill section → check): +// geometry radius corners ∈ {--radius-*} ∪ {0}; border sides ∈ {0, 1px} +// shadows nothing casts a shadow — no box-shadow, text-shadow, or +// drop-shadow() filter, with no switch (Law 2) +// colour gold ramp never rendered; yellow ONLY as black-on-yellow or +// yellow-on-dark — never a yellow foreground on a light surface +// type font-size ∈ the size tokens; font-weight ∈ {440, 530, 600} (Law 5) +// a11y icon-only buttons carry a label; no interactive control nested +// inside another (the titlebar drag-wrapper regression); +// a visible focus ring on every Tab stop; reduced motion honoured +// ============================================================================ + +const schemes = ["light", "dark"] as const +type Scheme = (typeof schemes)[number] + +const routes = { + landing: "/", + session: `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`, +} as const + + +async function bootApp(page: Page, scheme: Scheme, route: string) { + await page.emulateMedia({ colorScheme: scheme }) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + // oc-theme-preload.js honours ?colorScheme= (the extension's own bridge), so + // the explicit attribute path and the media-query path are both exercised. + const sep = route.includes("?") ? "&" : "?" + await page.goto(`${route}${sep}colorScheme=${scheme}`) + await expectAppVisible(page.locator('[data-component^="prompt-input"]').first()) + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.colorScheme)) + .toBe(scheme) +} + +type Violation = { rule: string; el: string; detail: string } +type Sweep = { tokens: Record; counts: Record; sample: Violation[]; scanned: number } + +function sweep(page: Page): Promise { + return page.evaluate(() => { + const root = document.documentElement + const rootStyle = getComputedStyle(root) + + // ---- tokens, read from the brand sheet's :root rule (never hardcoded) ---- + const tokens: Record = {} + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList + try { + rules = sheet.cssRules + } catch { + continue + } + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSStyleRule) || !/:root/.test(rule.selectorText)) continue + for (const name of Array.from(rule.style)) { + if (/^--(radius|font-size|text-(2xs|xs|sm|md|lg|xl|2xl|3xl)$|space|spacing|border-width|accent|focus-ring|font-weight|motion|measure)/.test(name)) { + tokens[name] = rootStyle.getPropertyValue(name).trim() + } + } + } + } + const px = (v: string) => (v.endsWith("px") ? parseFloat(v) : NaN) + const radii = new Set( + Object.entries(tokens) + .filter(([k]) => k.startsWith("--radius-")) + .map(([, v]) => px(v)) + .filter((n) => !Number.isNaN(n)), + ) + const sizes = new Set( + Object.entries(tokens) + .filter(([k]) => k.startsWith("--font-size-") || /^--text-(2xs|xs|sm|md|lg|xl|2xl|3xl)$/.test(k)) + .map(([, v]) => px(v)) + .filter((n) => !Number.isNaN(n)), + ) + const weights = new Set([440, 530, 600]) + + // ---- colour helpers ------------------------------------------------------- + const parse = (c: string): [number, number, number, number] | null => { + let m = c.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)/) + if (m) { + const a = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]) + return [+m[1], +m[2], +m[3], a] + } + m = c.match(/color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)/) + if (m) { + const a = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]) + return [+m[1] * 255, +m[2] * 255, +m[3] * 255, a] + } + return null + } + const hex = (h: string) => { + const n = parseInt(h.slice(1), 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const + } + const near = (a: readonly number[] | null, b: readonly number[], tol = 14) => + !!a && Math.abs(a[0] - b[0]) <= tol && Math.abs(a[1] - b[1]) <= tol && Math.abs(a[2] - b[2]) <= tol + const lum = (c: readonly number[]) => { + const f = (v: number) => { + const s = v / 255 + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) + } + // the dead gold ramp, from the skill — must never render + const gold = ["#9a7414", "#c99a2e", "#e2b959", "#857a00", "#9c8317", "#f2c94c"].map(hex) + const accentRaw = tokens["--accent"] ?? "" + const accent = accentRaw.startsWith("#") ? hex(accentRaw) : parse(accentRaw) + const effectiveBg = (el: Element): readonly number[] => { + let node: Element | null = el + while (node) { + const c = parse(getComputedStyle(node).backgroundColor) + if (c && c[3] > 0.05) return c + node = node.parentElement + } + return [255, 255, 255] + } + + // ---- the sweep ------------------------------------------------------------- + const counts: Record = {} + const sample: Violation[] = [] + const CAP = 40 + const describe = (el: Element) => { + const dc = el.getAttribute("data-component") + const ds = el.getAttribute("data-slot") + const id = el.id ? `#${el.id}` : "" + const cls = Array.from(el.classList).slice(0, 2).map((c) => `.${c}`).join("") + const label = el.getAttribute("aria-label") + return `${el.tagName.toLowerCase()}${id}${dc ? `[data-component=${dc}]` : ""}${ds ? `[data-slot=${ds}]` : ""}${cls}${label ? `{${label}}` : ""}` + } + const hit = (rule: string, el: Element, detail: string) => { + counts[rule] = (counts[rule] ?? 0) + 1 + if (sample.filter((s) => s.rule === rule).length < CAP) sample.push({ rule, el: describe(el), detail }) + } + const interactive = "button, [role='button'], a[href], input, select, textarea, [role='link'], [role='menuitem'], [role='tab']" + + let scanned = 0 + for (const el of Array.from(document.querySelectorAll("*"))) { + if (el.closest("script, style, template, noscript")) continue + const cs = getComputedStyle(el) + if (cs.display === "none" || cs.visibility === "hidden" || el.getClientRects().length === 0) continue + scanned++ + const isHtml = el instanceof HTMLElement + const rect = el.getBoundingClientRect() + + // colour rules apply to everything that paints (incl. SVG fills/strokes) + for (const prop of ["color", "backgroundColor", "borderTopColor", "borderBottomColor", "borderLeftColor", "borderRightColor", "fill", "stroke", "outlineColor"] as const) { + const c = parse(cs[prop]) + if (!c || c[3] < 0.05) continue + if (gold.some((g) => near(c, g))) hit("gold", el, `${prop}: ${cs[prop]}`) + } + if (accent) { + const bg = parse(cs.backgroundColor) + const fg = parse(cs.color) + if (bg && bg[3] > 0.5 && near(bg, accent) && fg && fg[3] > 0.5 && lum(fg) > 0.2) + hit("yellow-fill-needs-ink", el, `yellow fill with non-ink text color: ${cs.color}`) + if (fg && fg[3] > 0.5 && near(fg, accent)) { + const under = effectiveBg(el) + if (lum(under) > 0.35) hit("yellow-on-light", el, `yellow foreground over light surface (bg ${under.map(Math.round).join(",")})`) + } + } + + if (!isHtml) continue + + // geometry: radius ∈ tokens ∪ {0} ∪ {full} + for (const corner of ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"] as const) { + const v = cs[corner] + if (v === "0px" || v === "50%") continue + const n = px(v.split(" ")[0]) + const full = n >= 999 || (rect.width > 0 && n >= Math.min(rect.width, rect.height) / 2 - 0.5) + if (Number.isNaN(n) || (!radii.has(n) && !full)) hit("radius", el, `${corner}: ${v} (tokens: ${[...radii].join("/")}px)`) + break // one corner per element is enough for the report + } + // geometry: border sides ∈ {0, 1px} + for (const side of ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"] as const) { + const n = px(cs[side]) + if (n !== 0 && Math.abs(n - 1) > 0.01) { + hit("border-width", el, `${side}: ${cs[side]}`) + break + } + } + // shadows — Law 2: nothing casts a shadow (no switch) + if (cs.boxShadow && cs.boxShadow !== "none") { + // Tailwind's transparent zero-spread ring-reset layers paint nothing + const layers = cs.boxShadow.split(/,(?![^(]*\))/).map((l) => l.trim()) + const paints = layers.some((l) => { + const c = parse(l) + if (!c || c[3] < 0.02) return false + const nums = (l.replace(/rgba?\([^)]*\)|color\([^)]*\)/, "").match(/-?\d*\.?\d+px/g) ?? []).map(parseFloat) + const [x = 0, y = 0, blur = 0, spread = 0] = nums + return x !== 0 || y !== 0 || blur > 0 || spread > 0 + }) + if (paints) hit("box-shadow", el, cs.boxShadow.slice(0, 90)) + } + if (cs.textShadow && cs.textShadow !== "none") hit("text-shadow", el, cs.textShadow.slice(0, 60)) + if (/drop-shadow\(/.test(cs.filter)) hit("drop-shadow", el, cs.filter.slice(0, 60)) + // type + const text = (el.childNodes.length && Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent!.trim())) || false + if (text) { + const fs = px(cs.fontSize) + if (sizes.size && !sizes.has(fs)) hit("font-size", el, `${cs.fontSize} (tokens: ${[...sizes].join("/")}px)`) + const fw = parseInt(cs.fontWeight, 10) + if (!weights.has(fw)) hit("font-weight", el, `${cs.fontWeight}`) + } + // a11y: icon-only controls need a name; controls never nest + if (el.matches("button, [role='button']")) { + const name = (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") || el.getAttribute("title") || el.textContent || "").trim() + if (!name) hit("unlabelled-button", el, "no aria-label / text") + if (el.querySelector(interactive)) hit("nested-interactive", el, `contains ${describe(el.querySelector(interactive)!)}`) + if (cs.cursor !== "pointer" && !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true") + hit("cursor", el, `cursor: ${cs.cursor}`) + } else if (el.matches("a[href]") && el.querySelector(interactive)) { + hit("nested-interactive", el, `link contains ${describe(el.querySelector(interactive)!)}`) + } + } + return { tokens, counts, sample, scanned } + }) +} + +function report(s: Sweep) { + const lines = Object.entries(s.counts) + .sort((a, b) => b[1] - a[1]) + .map(([rule, n]) => { + const ex = s.sample.filter((v) => v.rule === rule).slice(0, 4).map((v) => ` ${v.el} → ${v.detail}`) + return ` ${rule.padEnd(24)} ${String(n).padStart(4)}\n${ex.join("\n")}` + }) + return `scanned ${s.scanned} elements\n${lines.join("\n")}` +} + +for (const scheme of schemes) { + for (const [name, route] of Object.entries(routes)) { + test(`design-system conformance — ${name} · ${scheme}`, async ({ page }, testInfo) => { + await bootApp(page, scheme, route) + const s = await sweep(page) + await testInfo.attach(`conformance-${name}-${scheme}.json`, { body: JSON.stringify(s, null, 2), contentType: "application/json" }) + expect(Object.keys(s.tokens).length, "brand tokens must be readable at runtime (design-system/tokens.css loaded)").toBeGreaterThan(5) + expect(s.counts, `\n${report(s)}\n`).toEqual({}) + }) + } +} + +test("every Tab stop shows a visible focus ring (light + dark)", async ({ page }) => { + const missing: string[] = [] + for (const scheme of schemes) { + await bootApp(page, scheme, routes.session) + await page.keyboard.press("Tab") + for (let i = 0; i < 40; i++) { + const r = await page.evaluate(() => { + const el = document.activeElement + if (!el || el === document.body) return null + const w = window as unknown as { __focusSeen?: WeakSet } + w.__focusSeen ??= new WeakSet() + if (w.__focusSeen.has(el)) return null // wrapped around to the first stop + w.__focusSeen.add(el) + const cs = getComputedStyle(el) + const key = el.tagName + "|" + (el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 30) || "") + "|" + Array.from(el.classList).slice(0, 2).join(".") + const ring = (cs.outlineStyle !== "none" && parseFloat(cs.outlineWidth) > 0) || (cs.boxShadow !== "none" && /0px 0px 0px \d/.test(cs.boxShadow)) + return { key, ring, outline: `${cs.outlineStyle} ${cs.outlineWidth} ${cs.outlineColor}` } + }) + if (!r) break + if (!r.ring) missing.push(`${scheme}: ${r.key} (outline: ${r.outline})`) + await page.keyboard.press("Tab") + } + } + expect(missing, `\n${missing.join("\n")}\n`).toEqual([]) +}) + +test("prefers-reduced-motion stops running animations (except those the brand sheet keeps on purpose)", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }) + await bootApp(page, "light", routes.session) + const animated = await page.evaluate(() => { + // An animation may keep running under reduced motion ONLY if the brand + // sheet says so explicitly: a rule inside an `@media (prefers-reduced-motion: + // reduce)` block that sets a non-zero animation-duration (design-system/components.css + // does this for the timeline-enter FADE — rise and blur are zeroed, and a + // withheld block popping in with no signal is worse than a fade). + const kept: string[] = [] + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList + try { + rules = sheet.cssRules + } catch { + continue + } + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSMediaRule) || !/prefers-reduced-motion/.test(rule.conditionText)) continue + for (const inner of Array.from(rule.cssRules)) { + if (!(inner instanceof CSSStyleRule)) continue + const d = inner.style.getPropertyValue("animation-duration").trim() + // a re-applied duration is a declared exception whether literal or var() + if (d && (d.startsWith("var(") || parseFloat(d) > 0.02)) kept.push(inner.selectorText) + else if (inner.style.getPropertyValue("animation").trim()) kept.push(inner.selectorText) + } + } + } + return Array.from(document.querySelectorAll("*")) + .filter((el) => { + const cs = getComputedStyle(el) + if (cs.animationName === "none" || parseFloat(cs.animationDuration) <= 0.02 || cs.animationPlayState === "paused" || el.getClientRects().length === 0) return false + return !kept.some((sel) => { + try { + return el.matches(sel) + } catch { + return false + } + }) + }) + .slice(0, 25) + .map((el) => `${el.tagName.toLowerCase()}${el.getAttribute("data-component") ? `[data-component=${el.getAttribute("data-component")}]` : ""}.${Array.from(el.classList).slice(0, 2).join(".")} → ${getComputedStyle(el).animationName} ${getComputedStyle(el).animationDuration}`) + }) + expect(animated, `\n${animated.join("\n")}\n`).toEqual([]) +}) diff --git a/packages/app/src/components/report-bug-button.css b/packages/app/src/components/report-bug-button.css index 2521fd3e6..c6974150e 100644 --- a/packages/app/src/components/report-bug-button.css +++ b/packages/app/src/components/report-bug-button.css @@ -7,7 +7,7 @@ forced states ([data-state]) exist so the story can render the matrix. */ [data-component="icon-button-v2"][data-action="report-bug"] { - border-radius: var(--radius-md); /* control radius token (design-polish.css) */ + border-radius: var(--radius-md); /* control radius token (design-system/tokens.css) */ color: var(--v2-state-fg-danger); opacity: 0.55; cursor: pointer; diff --git a/packages/app/src/components/report-bug-button.stories.tsx b/packages/app/src/components/report-bug-button.stories.tsx index 0f1f38e41..0dbac3bb5 100644 --- a/packages/app/src/components/report-bug-button.stories.tsx +++ b/packages/app/src/components/report-bug-button.stories.tsx @@ -4,9 +4,10 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { PromptInputV2SubmitButton } from "@opencode-ai/session-ui/v2/prompt-input" import { oc2Theme, resolveThemeVariantV2 } from "@opencode-ai/ui/theme" import { ReportBugButton } from "@/components/report-bug-button" -// The app entry loads design-polish.css globally (it owns --radius-md, which +// The app entry loads design-system/tokens.css and components.css globally (it owns --radius-md, which // the button's css consumes); stories must pull it themselves. -import "@/design-polish.css" +import "@/design-system/tokens.css" +import "@/design-system/components.css" // amicode/opencode#116: the report-a-bug button for the v2 composer. The // interaction-state contract (default / hover / active / focus-visible / diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 4c9037826..580068ce7 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -81,7 +81,7 @@ export function NewSessionView(props: NewSessionViewProps) { {/* amicode: mark only — the AMICODE wordmark beneath it was dropped as redundant. Accent via icon-icon-accent: yellow on dark, neutral ink on light. */} - + {/* amicode: getting-started block (tagline + how-it-works + starter chips) */}