diff --git a/docs-site/src/ui/context/layout/detail-panel.md b/docs-site/src/ui/context/layout/detail-panel.md index 8b01edb5f..8e14b33a3 100644 --- a/docs-site/src/ui/context/layout/detail-panel.md +++ b/docs-site/src/ui/context/layout/detail-panel.md @@ -57,7 +57,8 @@ through spacing and a sentence-case title, not a card, divider, or decorative background. Pass `icon` for a fixed section icon slot and `tone` to distinguish portable `accent`, `neutral`, `success`, `warning`, or `danger` roles through text color only. Pass `description` for short supporting context, `meta` for a -count or state, and `actions` for a normal section. A normal section may omit +count or state, and `actions` for a normal section. Only the description is +muted: a field in `actions` keeps its own description and error styles. A normal section may omit its body to represent a compact, actionable empty group. Set `collapsible` for secondary content. Closed sections show a compact action row; open sections show a heading with a collapse button on the right. Their content stays mounted when diff --git a/docs-site/src/ui/context/layout/overview.md b/docs-site/src/ui/context/layout/overview.md index 55f194a43..bf4d205f9 100644 --- a/docs-site/src/ui/context/layout/overview.md +++ b/docs-site/src/ui/context/layout/overview.md @@ -32,7 +32,7 @@ sidebar-first overview. Put the page's single primary action, such as a create button or a create menu, in `actions`. It sits at the trailing edge of the page header. -Use `AppOverview.Main` for the collection. Its `toolbar` slot suits a search field or one compact filter. +Use `AppOverview.Main` for the collection. Its `toolbar` slot suits a search field or one compact filter. A field there keeps its own description and error styles. Use `AppOverview.Cards` when the collection is a small set of objects. It lays out as many columns of at least 19rem as fit and collapses to one column on diff --git a/docs-site/src/ui/context/layout/panel-dialog.md b/docs-site/src/ui/context/layout/panel-dialog.md index 2b11fdb07..dcf2f71ae 100644 --- a/docs-site/src/ui/context/layout/panel-dialog.md +++ b/docs-site/src/ui/context/layout/panel-dialog.md @@ -51,6 +51,8 @@ separately. `surface="contained"` is the default modal treatment. `surface="floating"` makes the header, footer, and each section separate paper surfaces for settings-style pages. Use `PanelDialog.Section` for meaningful field groups. Keep the primary save action in `PanelDialog.Footer`. +Only the section's own subtitle is muted: fields, notices, and paragraphs in the +section keep their own description, error, and text styles. Pass `hideable` to a section for optional settings. Closed sections show a flat summary row with an eye icon at the trailing edge. Open sections use the normal diff --git a/packages/ui/src/layout/AppOverview.tsx b/packages/ui/src/layout/AppOverview.tsx index 8f9628d9b..127e625b7 100644 --- a/packages/ui/src/layout/AppOverview.tsx +++ b/packages/ui/src/layout/AppOverview.tsx @@ -50,7 +50,7 @@ const AppOverviewPanelHeader = (props: Pick

{props.title}

-

{props.description}

+

{props.description}

diff --git a/packages/ui/src/layout/DetailPanel.tsx b/packages/ui/src/layout/DetailPanel.tsx index 6796c1f91..e4b5fc5de 100644 --- a/packages/ui/src/layout/DetailPanel.tsx +++ b/packages/ui/src/layout/DetailPanel.tsx @@ -350,7 +350,7 @@ const DetailPanelSection = (props: DetailPanelSectionProps): JSX.Element => {

{props.title}

-

{props.description}

+

{props.description}

@@ -398,7 +398,7 @@ const DetailPanelSection = (props: DetailPanelSectionProps): JSX.Element => {

{props.title}

-

{props.description}

+

{props.description}

diff --git a/packages/ui/src/layout/PanelDialog.tsx b/packages/ui/src/layout/PanelDialog.tsx index c19051cc9..ccace0a1d 100644 --- a/packages/ui/src/layout/PanelDialog.tsx +++ b/packages/ui/src/layout/PanelDialog.tsx @@ -229,7 +229,7 @@ const PanelDialogSection = (props: PanelDialogSectionProps): JSX.Element => {

{props.title}

-

{props.subtitle}

+

{props.subtitle}

diff --git a/packages/ui/src/layout/container-field-parts.browser.test.ts b/packages/ui/src/layout/container-field-parts.browser.test.ts new file mode 100644 index 000000000..f1855b941 --- /dev/null +++ b/packages/ui/src/layout/container-field-parts.browser.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createConfig } from "@k2b/ssr"; +import { type Browser, chromium } from "playwright"; +import { createComponent, type JSX } from "solid-js"; +import { renderToString } from "solid-js/web"; + +// Which rule wins for a field's description and error is decided by the +// cascade, which happy-dom does not model, so a real engine renders the +// shipped stylesheet. Containers style their own subtitle by class: a bare +// `p` selector there would outrank the styles of fields and app paragraphs inside. +const root = mkdtempSync(resolve(tmpdir(), "k2b-ui-container-field-parts-")); +const { plugin } = createConfig({ dev: true, rootDir: root }); +Bun.plugin(plugin()); +process.once("exit", () => rmSync(root, { recursive: true, force: true })); + +const { TextInput } = await import("../inputs/TextInput"); +const { default: AppOverview } = await import("./AppOverview"); +const { default: DetailPanel } = await import("./DetailPanel"); +const { default: PanelDialog } = await import("./PanelDialog"); + +const css = readFileSync(resolve(import.meta.dir, "../../dist/styles.css"), "utf8"); + +let browser: Browser; +beforeAll(async () => { + browser = await chromium.launch(); +}, 30_000); +afterAll(async () => { + await browser?.close(); +}); + +const field = () => createComponent(TextInput, { label: "Name", description: "Shown to everyone.", error: "Name is required.", value: "" }); + +const containers: [string, () => JSX.Element, string][] = [ + [ + "panel dialog section body", + () => + createComponent(PanelDialog.Section, { + title: "General", + subtitle: "Basic details.", + get children() { + return field(); + }, + }), + ".k2b-panel-dialog__section-subtitle", + ], + [ + "app overview toolbar", + () => + createComponent(AppOverview.Main, { + title: "Your bases", + description: "Everything you can open.", + get toolbar() { + return field(); + }, + children: "", + }), + ".k2b-app-overview__panel-description", + ], + [ + "detail panel section actions", + () => + createComponent(DetailPanel.Section, { + title: "Owner", + description: "Who answers questions.", + get actions() { + return field(); + }, + }), + ".k2b-detail-panel__section-description", + ], +]; + +const partStyles = (selector: string) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing ${selector}`); + const style = getComputedStyle(element); + return { color: style.color, fontSize: style.fontSize, margin: style.margin }; +}; + +describe("field parts inside containers", () => { + for (const [name, container, subtitle] of containers) { + test(`${name} keeps the styles of a field and of an app paragraph beside it`, async () => { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + try { + // Applications style their paragraphs with utilities in a cascade + // layer, which any unlayered container rule for `p` would outrank. + await page.setContent( + `` + + `` + + `
${renderToString(field)}
${renderToString(container)}
`, + ); + const read = (selector: string) => page.evaluate(partStyles, selector); + + const muted = await page.evaluate(() => { + const probe = document.createElement("span"); + probe.style.color = "var(--k2b-text-muted)"; + document.body.append(probe); + return getComputedStyle(probe).color; + }); + const error = await read("#container .k2b-field__error"); + expect(error).toEqual(await read("#reference .k2b-field__error")); + expect(error.color).not.toBe(muted); + expect(await read("#container .k2b-field__description")).toEqual(await read("#reference .k2b-field__description")); + expect((await read(`#container ${subtitle}`)).color).toBe(muted); + + const hint = await page.evaluate(() => { + const paragraph = document.createElement("p"); + paragraph.className = "app-hint"; + const field = document.querySelector("#container .k2b-field"); + if (!field) throw new Error("Missing field"); + field.after(paragraph); + const style = getComputedStyle(paragraph); + return { color: style.color, fontSize: style.fontSize }; + }); + expect(hint).toEqual({ color: "rgb(1, 2, 3)", fontSize: "14px" }); + } finally { + await page.close(); + } + }); + } +}); diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 5b88ea193..aebd8e43c 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -2818,7 +2818,6 @@ background: color-mix(in srgb, var(--k2b-detail-panel-accent, var(--k2b-accent-600)) 9%, transparent); } -.k2b-ui .k2b-detail-panel__section-header p, .k2b-ui .k2b-detail-panel__section-description { margin: 0.125rem 0 0; color: var(--k2b-text-muted); @@ -3074,8 +3073,7 @@ .k2b-ui .k2b-panel-dialog__heading h2, .k2b-ui .k2b-panel-dialog__heading p, -.k2b-ui .k2b-panel-dialog__section h3, -.k2b-ui .k2b-panel-dialog__section p { +.k2b-ui .k2b-panel-dialog__section h3 { margin: 0; } @@ -3095,10 +3093,11 @@ overflow-wrap: anywhere; } -.k2b-ui .k2b-panel-dialog__section p { - margin-top: 0.125rem; +/* A class, not `p`: section bodies hold fields, and a bare `p` here would + outrank their description and error styles. */ +.k2b-ui .k2b-panel-dialog__section > header .k2b-panel-dialog__section-subtitle { + margin: 0.125rem 0 0; color: var(--k2b-text-muted); - font-size: 0.6875rem; } .k2b-ui .k2b-panel-dialog__actions { diff --git a/packages/ui/src/styles/layout-parity.css b/packages/ui/src/styles/layout-parity.css index da48a0262..42caebbd1 100644 --- a/packages/ui/src/styles/layout-parity.css +++ b/packages/ui/src/styles/layout-parity.css @@ -131,7 +131,7 @@ } .k2b-ui .k2b-app-overview__panel-header h2, -.k2b-ui .k2b-app-overview__panel-header p { +.k2b-ui .k2b-app-overview__panel-description { margin: 0; } @@ -140,7 +140,9 @@ font-weight: 650; } -.k2b-ui .k2b-app-overview__panel-header p { +/* A class, not `p`: the toolbar beside it holds fields with their own + description and error styles. */ +.k2b-ui .k2b-app-overview__panel-description { color: var(--k2b-text-muted); font-size: 0.75rem; }