diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 6c28cebbff..7072e2ef0e 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -37,6 +37,12 @@ matching component slot, just as for SVG icons. These slots constrain catalog icons to the component's size; a standalone `Icon` retains its explicit size (24px by default). Do not shrink the catalog globally to correct a slot mismatch. +`IconButton` defaults to `quiet`: its resting surface is transparent, hover and +pressed states use shared action feedback, and keyboard focus keeps a visible +focus ring. Use it for toolbar, dialog, and row utilities. `fill` and `primary` +keep an opaque backing surface for persistent emphasis. Disabled quiet actions +remain transparent and do not show hover or pressed feedback. + The catalog uses exported vectors, including their view boxes and per-path opacity. Theme colors remain caller-owned through `currentColor`. Asset fingerprints are reviewed with intentional resource updates so replacing a diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts b/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts index 70bc967159..fd70504fff 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts +++ b/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const iconButtonMeta = { category: "action", - description: "A labeled icon-only action with opaque quiet, filled, and primary presentation variants.", + description: "A labeled icon-only action with a transparent quiet variant and opaque filled and primary variants.", maturity: "stable", name: "IconButton", props: [ diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.module.css b/design-system/packages/ui/src/components/IconButton/IconButton.module.css index 2112a93c74..9553635ae4 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.module.css +++ b/design-system/packages/ui/src/components/IconButton/IconButton.module.css @@ -1,6 +1,6 @@ @layer openbitfun.components { .button { - --_icon-button-background: var(--openbitfun-color-surface-tertiary); + --_icon-button-background: transparent; --_icon-button-background-hover: var(--openbitfun-color-action-neutral-surface); --_icon-button-background-active: var(--openbitfun-color-action-neutral-surface-pressed); --_icon-button-content: var(--openbitfun-color-action-neutral-content); @@ -19,7 +19,7 @@ border: 0; border-radius: var(--openbitfun-radius-sm); color: var(--_icon-button-content); - background: var(--openbitfun-color-surface-tertiary); + background: transparent; cursor: pointer; isolation: isolate; transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); @@ -55,6 +55,11 @@ cursor: not-allowed; } + .button[data-openbitfun-variant="fill"], + .button[data-openbitfun-variant="primary"] { + background: var(--openbitfun-color-surface-tertiary); + } + .button[data-openbitfun-variant="fill"] { --_icon-button-background: var(--openbitfun-color-action-neutral-surface); --_icon-button-background-hover: var(--openbitfun-color-action-neutral-surface-hover); diff --git a/design-system/packages/ui/tests/icon-button.test.mjs b/design-system/packages/ui/tests/icon-button.test.mjs index b5fcdc568a..739c1ea338 100644 --- a/design-system/packages/ui/tests/icon-button.test.mjs +++ b/design-system/packages/ui/tests/icon-button.test.mjs @@ -80,7 +80,7 @@ test("IconButton styles consume shared action and geometry tokens", async () => assert.match(styles, /--openbitfun-radius-pill/); }); -test("every IconButton variant is composited over an opaque surface", async () => { +test("quiet IconButtons are transparent at rest while emphasized variants retain an opaque surface", async () => { const styles = await readFile( new URL("../src/components/IconButton/IconButton.module.css", import.meta.url), "utf8", @@ -88,11 +88,19 @@ test("every IconButton variant is composited over an opaque surface", async () = assert.match( styles, - /\.button\s*\{[^}]*background:\s*var\(--openbitfun-color-surface-tertiary\)/s, + /\.button\s*\{[^}]*--_icon-button-background:\s*transparent;[^}]*\n\s*background:\s*transparent;/s, ); assert.match( styles, /\.button::before\s*\{[^}]*background:\s*var\(--_icon-button-background\)/s, ); - assert.doesNotMatch(styles, /--_icon-button-background(?:-hover|-active)?:\s*transparent/); + assert.match( + styles, + /\.button\[data-openbitfun-variant="fill"\],\s*\.button\[data-openbitfun-variant="primary"\]\s*\{[^}]*background:\s*var\(--openbitfun-color-surface-tertiary\)/s, + ); + assert.doesNotMatch(styles, /--_icon-button-background-(?:hover|active):\s*transparent/); + for (const state of ["hover", "active"]) { + assert.ok(styles.includes(`:is(:${state}, [data-openbitfun-preview-state="${state}"]):not(:disabled)::before`)); + } + assert.match(styles, /\.button:focus-visible\s*\{[^}]*outline:.*var\(--openbitfun-color-focus-ring\)/s); }); diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index d7677e65e0..ccaaa8a721 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -1,45 +1,84 @@ /** - * About dialog styles. - * Modal owns the outer surface; this file owns the product, metadata, and CTA layout. + * About dialog composition. Dialog owns the surface, focus and scrolling; + * this file owns the brand artwork, build details and repository invitation. */ +.openbitfun-about-dialog__close { + position: absolute; + top: var(--openbitfun-space-3); + right: var(--openbitfun-space-3); + z-index: 1; +} + .openbitfun-about-dialog__modal-content { - overflow: hidden; + overscroll-behavior: contain; } .openbitfun-about-dialog__content { display: flex; + flex: 0 0 auto; flex-direction: column; - height: min(590px, calc(100vh - 112px)); - min-height: min(540px, calc(100vh - 112px)); - overflow: hidden; } .openbitfun-about-dialog__body { display: grid; - grid-template-columns: minmax(330px, 0.38fr) minmax(0, 0.62fr); - flex: 1; - min-height: 0; + grid-template-columns: minmax(0, 192px) minmax(0, 1fr); + column-gap: var(--openbitfun-space-8); + min-width: 0; + padding: 40px var(--openbitfun-space-8) var(--openbitfun-space-8); } .openbitfun-about-dialog__brand { - position: relative; display: flex; flex-direction: column; - align-items: flex-start; - justify-content: flex-start; + align-items: center; + justify-content: center; + gap: var(--openbitfun-space-6); + min-width: 0; +} + +.openbitfun-about-dialog__brand-statement { + margin: 0; + color: var(--openbitfun-color-content-muted); + font-family: var(--openbitfun-type-label-sm-font-family); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-modifier-leading-loose-line-height); + letter-spacing: var(--openbitfun-type-modifier-tracking-expanded-letter-spacing); + text-align: center; + white-space: pre-line; +} + +.openbitfun-about-dialog__artwork { + width: 100%; + max-width: 192px; +} + +.openbitfun-about-dialog__brand-mark { + display: block; + width: 100%; + height: auto; + object-fit: contain; + user-select: none; + pointer-events: none; +} + +.openbitfun-about-dialog__brand-mark--light { + display: none; +} + +[data-color-scheme='dark'] .openbitfun-about-dialog__brand-mark--dark { + display: none; +} + +[data-color-scheme='dark'] .openbitfun-about-dialog__brand-mark--light { + display: block; +} + +.openbitfun-about-dialog__metadata { + display: flex; + flex-direction: column; min-width: 0; - padding: 56px 42px 40px 56px; - - &::after { - position: absolute; - top: 36px; - right: 0; - bottom: 36px; - width: 1px; - background: var(--openbitfun-color-border-subtle); - content: ''; - } } .openbitfun-about-dialog__brand-copy { @@ -49,56 +88,116 @@ .openbitfun-about-dialog__title { margin: 0; color: var(--openbitfun-color-content-primary); - font-family:var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-display-xl-font-size); + font-family: var(--openbitfun-type-display-sm-font-family); + font-size: var(--openbitfun-type-display-sm-font-size); font-weight: var(--openbitfun-type-body-sm-font-weight); - line-height: var(--openbitfun-type-modifier-leading-none-line-height); + line-height: var(--openbitfun-type-display-sm-line-height); letter-spacing: var(--openbitfun-type-modifier-tracking-tighter-letter-spacing); + white-space: normal; + overflow-wrap: anywhere; } .openbitfun-about-dialog__tagline { - margin: 16px 0 0; + margin: var(--openbitfun-space-2) 0 0; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-body-md-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-support-line-height); } -.openbitfun-about-dialog__release { - width: 100%; - margin-top: 34px; +.openbitfun-about-dialog__details { + margin-top: var(--openbitfun-space-8); } -.openbitfun-about-dialog__release-summary { - display: flex; - flex-wrap: wrap; +.openbitfun-about-dialog__info-row { + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: center; - gap: 10px; + column-gap: var(--openbitfun-layout-field-horizontal-gap); + min-width: 0; + min-height: var(--openbitfun-control-height-lg); + margin: 0; + padding: var(--openbitfun-space-2) var(--openbitfun-space-1); } -.openbitfun-about-dialog__release-version { +.openbitfun-about-dialog__info-label { + min-width: 0; + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-label-md-line-height); + letter-spacing: var(--openbitfun-type-label-md-letter-spacing); +} + +.openbitfun-about-dialog__info-value { + min-width: 0; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-flow-title-font-size); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + letter-spacing: var(--openbitfun-type-body-sm-letter-spacing); font-variant-numeric: tabular-nums; - line-height: var(--openbitfun-type-label-md-line-height); + text-align: end; + overflow-wrap: anywhere; + + &--mono { + font-family: var(--openbitfun-type-code-md-font-family); + } +} + +.openbitfun-about-dialog__info-value-group { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--openbitfun-space-1) var(--openbitfun-space-2); + min-width: 0; + margin: 0; } -.openbitfun-about-dialog__channel-badge { +.openbitfun-about-dialog__channel-badge, +.openbitfun-about-dialog__copy-action { display: inline-flex; + flex: 0 0 auto; +} + +.openbitfun-about-dialog__star-callout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - min-height: 26px; - padding: 2px 10px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-secondary); + gap: var(--openbitfun-space-6); + padding-top: var(--openbitfun-space-6); + border-top: 1px solid var(--openbitfun-color-border-subtle); +} + +.openbitfun-about-dialog__star-copy { + min-width: 0; +} + +.openbitfun-about-dialog__star-title { + margin: 0; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-label-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); +} + +.openbitfun-about-dialog__star-description { + margin: var(--openbitfun-space-1) 0 0; + color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-label-md-font-size); - line-height: var(--openbitfun-type-modifier-leading-none-line-height); + line-height: var(--openbitfun-type-flow-support-line-height); +} + +.openbitfun-about-dialog__star-button { white-space: nowrap; } .openbitfun-about-dialog__update-card { - width: 100%; - margin-top: 20px; + min-width: 0; + margin-top: var(--openbitfun-space-5); } .openbitfun-about-dialog__update-card-actions { @@ -107,8 +206,9 @@ } .openbitfun-about-dialog__update-feedback { - width: 100%; - margin-top: 10px; + &:not(:empty) { + margin-top: 12px; + } } .openbitfun-about-dialog__update-alert { @@ -185,400 +285,68 @@ font-size: var(--openbitfun-type-label-md-font-size); line-height: var(--openbitfun-type-flow-support-line-height); - svg { + &-icon { flex-shrink: 0; margin-top: 2px; } - &--success svg { + &--success .openbitfun-about-dialog__update-status-icon { color: var(--openbitfun-color-status-success-content); } } -.openbitfun-about-dialog__dot-matrix { - position: absolute; - bottom: 20px; - left: 14px; - display: grid; - grid-template-columns: repeat(13, 2px); - grid-auto-rows: 2px; - gap: 14px; - width: max-content; - - span { - display: block; - width: 2px; - height: 2px; - border-radius: 50%; - background: var(--openbitfun-color-content-muted); - } -} - -.openbitfun-about-dialog__metadata { - display: flex; - flex-direction: column; - justify-content: center; - min-width: 0; - min-height: 0; - padding: 22px 40px 22px 38px; - overscroll-behavior: contain; -} - -.openbitfun-about-dialog__info-row { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - align-items: center; - gap: 18px; - min-width: 0; - min-height: 70px; - padding: 12px 0; - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - - &:last-child { - border-bottom: 0; - } -} - -.openbitfun-about-dialog__info-label { - display: flex; - align-items: center; - gap: 13px; - min-width: 0; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - line-height: var(--openbitfun-type-flow-support-line-height); - - svg { - flex-shrink: 0; - color: var(--openbitfun-color-content-muted); - } -} - -.openbitfun-about-dialog__info-value { - min-width: 0; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-lg-font-size); - font-weight: var(--openbitfun-type-body-sm-font-weight); - line-height: var(--openbitfun-type-flow-support-line-height); - text-align: right; - overflow-wrap: anywhere; - - &--version { - font-size: var(--openbitfun-type-flow-title-font-size); - font-variant-numeric: tabular-nums; - } - - &--mono { - font-family:var(--openbitfun-type-code-md-font-family); - font-variant-numeric: tabular-nums; - } - - &--branch { - font-size: var(--openbitfun-type-body-md-font-size); - } -} - -.openbitfun-about-dialog__info-value-group { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - min-width: 0; -} - -.openbitfun-about-dialog__copy-btn { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 0; - border: 1px solid transparent; - border-radius: var(--openbitfun-radius-sm); - background: transparent; - color: var(--openbitfun-color-content-muted); - cursor: pointer; - outline: none; - transition: - background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - - &:hover { - border-color: var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-primary); - } - - &:active { - transform: scale(0.94); - } - - &:focus-visible { - outline: 2px solid var(--openbitfun-color-accent-default); - outline-offset: 1px; - } -} - -.openbitfun-about-dialog__star-callout { - display: grid; - grid-template-columns: minmax(32px, 48px) 40px minmax(0, max-content) minmax(72px, 1fr) auto; - flex: 0 0 auto; - align-items: center; - gap: 12px; - min-height: 132px; - padding: 20px 42px 18px; -} - -.openbitfun-about-dialog__star-icon { - color: var(--openbitfun-color-content-primary); -} - -.openbitfun-about-dialog__star-copy { - min-width: 0; -} - -.openbitfun-about-dialog__star-title { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - - svg, - [data-openbitfun-component='icon'] { - flex: 0 0 auto; - } -} - -.openbitfun-about-dialog__star-description { - margin: 4px 0 0; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-md-font-size); - line-height: var(--openbitfun-type-flow-support-line-height); -} - -.openbitfun-about-dialog__star-rule { - display: block; - width: 100%; - height: 1px; - background: var(--openbitfun-color-border-strong); -} - -.openbitfun-about-dialog__star-button { - min-width: 132px; -} - .openbitfun-about-dialog__footer { display: flex; flex: 0 0 auto; - align-items: center; - min-height: 58px; - padding: 14px 42px; - border-top: 1px solid var(--openbitfun-color-border-subtle); + flex-direction: column; + gap: var(--openbitfun-space-6); + padding: 0 var(--openbitfun-space-8) var(--openbitfun-space-6); } .openbitfun-about-dialog__copyright { margin: 0; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-md-font-size); + font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } -@media (max-width: 900px) { - .openbitfun-about-dialog__body { - grid-template-columns: minmax(300px, 0.37fr) minmax(0, 0.63fr); - } - - .openbitfun-about-dialog__brand { - padding-right: 34px; - padding-left: 44px; - } - - .openbitfun-about-dialog__metadata { - padding-inline: 32px; - } - - .openbitfun-about-dialog__star-callout { - grid-template-columns: minmax(28px, 44px) 36px minmax(0, max-content) minmax(52px, 1fr) auto; - padding-inline: 34px; - } -} - -@media (max-width: 760px) { - .openbitfun-about-dialog__modal-content { - overflow-y: auto; - } - - .openbitfun-about-dialog__content { - height: auto; - min-height: 0; - overflow: visible; - } - +@media (max-width: 640px) { .openbitfun-about-dialog__body { - display: flex; - flex-direction: column; + grid-template-columns: minmax(0, 1fr); + row-gap: var(--openbitfun-space-6); + padding: 40px var(--openbitfun-space-6) var(--openbitfun-space-6); } .openbitfun-about-dialog__brand { + flex-direction: row; justify-content: flex-start; - padding: 30px 28px; - overflow: visible; - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - - &::after { - display: none; - } - } - - .openbitfun-about-dialog__dot-matrix { - display: none; - } - - .openbitfun-about-dialog__title { - font-size: var(--openbitfun-type-display-lg-font-size); - } - - .openbitfun-about-dialog__release { - margin-top: 26px; - } - - .openbitfun-about-dialog__metadata { - justify-content: flex-start; - padding: 8px 28px 18px; - overflow: visible; } - .openbitfun-about-dialog__info-row { - min-height: 66px; + .openbitfun-about-dialog__brand-statement { + text-align: left; } - .openbitfun-about-dialog__star-callout { - grid-template-columns: 34px minmax(0, 1fr) auto; - min-height: 120px; - padding: 18px 28px; + .openbitfun-about-dialog__artwork { + flex: 0 0 80px; } - .openbitfun-about-dialog__star-rule, - .openbitfun-about-dialog__star-rule--leading { - display: none; - } - - .openbitfun-about-dialog__star-button { - min-width: 132px; + .openbitfun-about-dialog__details { + margin-top: var(--openbitfun-space-6); } .openbitfun-about-dialog__footer { - padding-inline: 28px; + padding-inline: var(--openbitfun-space-6); } } -@media (max-width: 520px) { - .openbitfun-about-dialog__brand { - padding: 26px 20px; - } - - .openbitfun-about-dialog__title { - font-size: var(--openbitfun-type-display-md-font-size); - } - - .openbitfun-about-dialog__tagline { - margin-top: 10px; - font-size: var(--openbitfun-type-label-md-font-size); - } - - .openbitfun-about-dialog__metadata { - padding-inline: 20px; - } - - .openbitfun-about-dialog__info-row { - grid-template-columns: 100px minmax(0, 1fr); - gap: 10px; - } - - .openbitfun-about-dialog__info-label { - gap: 8px; - font-size: var(--openbitfun-type-label-md-font-size); - - svg { - width: 18px; - height: 18px; - } - } - - .openbitfun-about-dialog__info-value { - font-size: var(--openbitfun-type-body-md-font-size); - - &--version { - font-size: var(--openbitfun-type-body-lg-font-size); - } - - &--branch { - font-size: var(--openbitfun-type-label-md-font-size); - } - } - +@media (max-width: 480px) { .openbitfun-about-dialog__star-callout { - grid-template-columns: 1fr; - gap: 14px; - padding: 22px 20px; - } - - .openbitfun-about-dialog__star-icon { - display: none; + grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-3); } .openbitfun-about-dialog__star-button { - width: 100%; - } - - .openbitfun-about-dialog__footer { - padding-inline: 20px; - } -} - -@media (max-height: 700px) and (min-width: 761px) { - .openbitfun-about-dialog__content { - height: min(540px, calc(100vh - 96px)); - min-height: min(500px, calc(100vh - 96px)); - } - - .openbitfun-about-dialog__brand { - padding-block: 28px; - } - - .openbitfun-about-dialog__title { - font-size: var(--openbitfun-type-display-lg-font-size); - } - - .openbitfun-about-dialog__release { - margin-top: 24px; - } - - .openbitfun-about-dialog__metadata { - padding-block: 14px; - } - - .openbitfun-about-dialog__info-row { - min-height: 62px; - padding-block: 9px; - } - - .openbitfun-about-dialog__star-callout { - min-height: 104px; - padding-block: 14px; - } - - .openbitfun-about-dialog__footer { - min-height: 52px; - padding-block: 10px; + justify-self: start; } } @@ -587,8 +355,4 @@ animation: none; transform: none; } - - .openbitfun-about-dialog__copy-btn:active { - transform: none; - } } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 743dfa5795..effb18b53c 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -7,19 +7,19 @@ import { Alert, Button, + FieldGroup, + FieldRow, Icon, - ScrollArea, + IconButton, + StatusPill, Tooltip, Dialog, DialogBody, DialogClose, - DialogHeader, - DialogHeading, DialogTitle, } from '@openbitfun/ui'; import React, { useCallback, useEffect, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; -import { CalendarDays, Code2, ShieldCheck, Tag } from 'lucide-react'; import { formatBuildDate, formatDisplayedVersion, @@ -36,19 +36,6 @@ import './AboutDialog.scss'; const log = createLogger('AboutDialog'); const GITHUB_REPOSITORY_URL = 'https://github.com/GCWing/OpenBitFun'; -const ABOUT_DOT_MATRIX_COLUMNS = 13; -const ABOUT_DOT_MATRIX_ROWS = 7; -const ABOUT_DOT_MATRIX = Array.from( - { length: ABOUT_DOT_MATRIX_COLUMNS * ABOUT_DOT_MATRIX_ROWS }, - (_, index) => { - const column = index % ABOUT_DOT_MATRIX_COLUMNS; - const row = Math.floor(index / ABOUT_DOT_MATRIX_COLUMNS); - return { - id: index, - opacity: Math.max(0.12, 0.44 - column * 0.017 - row * 0.022), - }; - }, -); interface AboutDialogProps { /** Whether visible */ @@ -195,51 +182,175 @@ export const AboutDialog: React.FC = ({ { if (!nextOpen) onClose(); }} - size="2xl" + size="xl" + className="openbitfun-about-dialog" + aria-label={t('about.dialogTitle')} data-testid="about-dialog-modal" > - - - {t('about.dialogTitle')} - - - + -
-
- -
-

- {version.name} -

-

{t('about.tagline')}

+
+
+ -
-
- {displayedVersion} - +
+ - {releaseLabel} - -
+ {version.name} + +

{t('about.tagline')}

+ + + + +
+
+ {t('about.versionLabel')} +
+
+ + {displayedVersion} + + + {releaseLabel} + +
+
+
+ + +
+
+ {t('about.buildDate')} +
+
+ + {formatBuildDate(version.buildDate)} + +
+
+
+ + +
+
+ {t('about.commit')} +
+
+ + {version.gitCommit ?? t('about.notAvailable')} + + {version.gitCommit ? ( + + + } + onClick={() => void copyToClipboard(version.gitCommit ?? '', 'commit')} + aria-label={t('about.copyCommit')} + /> + + + ) : null} +
+
+
+ + +
+
+ {t('about.branch')} +
+
+ + {version.gitBranch ?? t('about.notAvailable')} + +
+
+
+ + +
+
+ {t('about.license')} +
+
+ + {licenseName} + +
+
+
+
{updateChecksAvailable ? (
= ({ {updateStatus === 'installed' ? (
-
) : null} -
- - - + +
- -
-
-
- - {displayedVersion} - -
- -
-
-
- - {formatBuildDate(version.buildDate)} - -
- -
-
-
-
- - {version.gitCommit ?? t('about.notAvailable')} - - {version.gitCommit ? ( - - - - ) : null} -
-
- -
-
-
- - {version.gitBranch ?? t('about.notAvailable')} - -
- -
-
-
- -
-
- -
-

- {legalCopyright} -

-
-
+ +
diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.scss b/src/web-ui/src/app/scenes/agents/AgentsScene.scss index 2ae91291c0..5600b40a32 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.scss +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.scss @@ -63,164 +63,192 @@ width: 124px; } - &__harness-zone { + &__harness { + min-width: 0; + container: agent-harness / inline-size; + } + + &__harness-zone.gallery-zone { display: grid; grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); - align-items: start; - column-gap: var(--openbitfun-space-6); - padding-bottom: var(--openbitfun-space-5); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); + grid-template-areas: 'intro diagram'; + align-items: center; + gap: var(--openbitfun-space-8); + padding: var(--openbitfun-space-5) var(--openbitfun-space-6); + border: 1px solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-lg); + background: var(--openbitfun-color-surface-scene); .gallery-zone__header { - min-height: 0; + grid-area: intro; align-items: flex-start; - padding-top: 2px; + min-height: 0; } .gallery-zone__heading { - gap: var(--openbitfun-space-1); - } - - .gallery-zone__title { - font-size: var(--openbitfun-type-body-md-font-size); + gap: var(--openbitfun-space-2); } .gallery-zone__subtitle { - font-size: var(--openbitfun-type-label-sm-font-size); + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-4); + max-width: 60ch; + font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-lg-line-height); } } + &__harness-caption { + margin: 0; + color: var(--openbitfun-color-content-muted); + font-family: var(--openbitfun-type-micro-font-family); + font-size: var(--openbitfun-type-micro-font-size); + font-weight: var(--openbitfun-type-micro-font-weight); + line-height: var(--openbitfun-type-micro-line-height); + letter-spacing: var(--openbitfun-type-micro-letter-spacing); + text-transform: uppercase; + } + &__harness-presentation { - min-width: 0; - min-height: 92px; + grid-area: diagram; display: grid; - grid-template-columns: minmax(0, 3fr) minmax(210px, 1.1fr); + grid-template-columns: 52px clamp(52px, 8cqi, 88px) minmax(0, 1fr) clamp(52px, 8cqi, 88px) 52px; align-items: center; + column-gap: var(--openbitfun-space-2); + min-width: 0; } - &__harness-track { - min-width: 0; - padding-inline: var(--openbitfun-space-3) var(--openbitfun-space-5); + &__harness-endpoint { + display: grid; + place-items: center; + width: 52px; + height: 52px; + box-sizing: border-box; + border: 1px solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-surface-panel); + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); } - &__harness-rail { + &__harness-connections { position: relative; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - align-items: center; - height: 14px; + align-self: stretch; + min-width: 0; color: var(--openbitfun-color-field-border-hover); + + svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.25; + } + } + + &__harness-connection-arrow { + display: none; } - &__harness-rail-line { + &__harness-result-arrow { position: absolute; - inset-inline: calc(100% / 6); top: 50%; - border-top: 1px solid var(--openbitfun-color-field-border-hover); + right: 0; transform: translateY(-50%); } - &__harness-rail-node { - position: relative; - z-index: 1; - justify-self: center; - border-radius: var(--openbitfun-radius-pill); - background: var(--openbitfun-color-surface-scene); - color: var(--openbitfun-color-field-border-hover); - - &.is-default { - color: var(--openbitfun-color-content-primary); - } - } - - &__harness-profile-grid { + &__harness-profiles { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-top: var(--openbitfun-space-1); + // All strategies share content-sized labels and one aligned route column. + grid-template-columns: 24px max-content fit-content(160px) minmax(84px, 1fr); + grid-template-rows: repeat(4, minmax(44px, 1fr)); + column-gap: var(--openbitfun-space-3); + min-width: 0; } &__harness-profile { - min-width: 0; - display: flex; - flex-direction: column; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; - gap: var(--openbitfun-space-1); - padding-inline: var(--openbitfun-space-2); - color: var(--openbitfun-color-content-primary); - text-align: center; + column-gap: inherit; + min-width: 0; - strong { - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); + &[data-openbitfun-profile='minimal'] { + color: var(--openbitfun-color-identity-harness-minimal); } - span { - display: -webkit-box; - max-width: 180px; - overflow: hidden; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-lg-line-height); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + &[data-openbitfun-profile='balanced'] { + color: var(--openbitfun-color-identity-harness-standard); + } + + &[data-openbitfun-profile='ultimate'] { + color: var(--openbitfun-color-identity-harness-ultimate); + } + + &[data-openbitfun-profile='creative'] { + color: var(--openbitfun-color-identity-harness-creative); } } - &__harness-creative { - min-width: 0; - min-height: 76px; - display: grid; - grid-template-columns: 32px minmax(0, 1fr); - align-items: center; - gap: var(--openbitfun-space-3); - padding-inline-start: var(--openbitfun-space-5); - border-inline-start: 1px solid var(--openbitfun-color-border-subtle); + &__harness-profile-icon { + color: inherit; } - &__harness-creative-icon { - display: inline-grid; - place-items: center; + &__harness-profile-name { color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-md-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); } - &__harness-creative-copy { + &__harness-profile-purpose { min-width: 0; - display: flex; - flex-direction: column; - gap: var(--openbitfun-space-1); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-label-sm-font-size); + line-height: var(--openbitfun-type-body-lg-line-height); + overflow-wrap: anywhere; } - &__harness-creative-heading { + &__harness-route { + position: relative; + height: 36px; min-width: 0; - display: flex; - align-items: baseline; - gap: var(--openbitfun-space-2); - span { - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - white-space: nowrap; + svg { + display: block; + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + opacity: 0.4; } + } - strong { - overflow: hidden; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - text-overflow: ellipsis; - white-space: nowrap; - } + &__harness-route-node, + &__harness-route-arrow { + position: absolute; + display: grid; + place-items: center; + color: inherit; + background: var(--openbitfun-color-surface-scene); + transform: translate(-50%, -50%); } - &__harness-creative-purpose { - display: -webkit-box; - overflow: hidden; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-lg-line-height); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + &__harness-route-node { + width: 9px; + height: 9px; + border-radius: var(--openbitfun-radius-pill); + } + + &__harness-route-arrow { + top: 50%; + left: 100%; + transform: translate(-100%, -50%); } &__subagent-model-select { @@ -243,17 +271,6 @@ &__search { width: min(360px, 100%); } - - &__harness-zone { - display: flex; - flex-direction: column; - gap: var(--openbitfun-space-3); - } - - &__harness-presentation { - width: 100%; - grid-template-columns: minmax(0, 1fr) minmax(200px, 0.42fr); - } } } @@ -297,22 +314,105 @@ &__search { width: 100%; } + } +} - &__harness-presentation { +@container agent-harness (max-width: 980px) { + .openbitfun-agents-scene { + &__harness-zone.gallery-zone { grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'intro' 'diagram'; gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-5); } - &__harness-track { - padding-inline: 0; + &__harness-zone.gallery-zone .gallery-zone__subtitle { + gap: var(--openbitfun-space-2); } + } +} - &__harness-creative { - min-height: 0; - padding-block-start: var(--openbitfun-space-3); - padding-inline-start: 0; - border-block-start: 1px solid var(--openbitfun-color-border-subtle); - border-inline-start: 0; +@container agent-harness (max-width: 680px) { + .openbitfun-agents-scene { + &__harness-presentation { + grid-template-columns: 44px 36px minmax(0, 1fr) 36px 44px; + column-gap: var(--openbitfun-space-1); + } + + &__harness-endpoint { + width: 44px; + height: 44px; + } + + &__harness-profiles { + grid-template-columns: 20px minmax(100px, 1fr) minmax(64px, 1fr); + grid-template-rows: repeat(4, minmax(52px, 1fr)); + column-gap: var(--openbitfun-space-2); + } + + &__harness-profile { + grid-template-rows: auto auto; + row-gap: 0; + padding-block: var(--openbitfun-space-1); + } + + &__harness-profile-icon { + grid-column: 1; + grid-row: 1 / 3; + width: 20px; + height: 20px; + } + + &__harness-profile-name { + grid-column: 2; + grid-row: 1; + align-self: end; + } + + &__harness-profile-purpose { + grid-column: 2; + grid-row: 2; + align-self: start; + } + + &__harness-route { + grid-column: 3; + grid-row: 1 / 3; + } + } +} + +@container agent-harness (max-width: 480px) { + .openbitfun-agents-scene { + &__harness-zone.gallery-zone { + padding: var(--openbitfun-space-4); + } + + &__harness-presentation { + grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-1); + } + + &__harness-endpoint { + justify-self: center; + } + + &__harness-connections { + display: grid; + place-items: center; + height: 16px; + + svg { + display: none; + } + } + + &__harness-connection-arrow { + display: inline-block; + } + + &__harness-result-arrow { + display: none; } } } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx index f267cc24c4..49d139f517 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx @@ -333,38 +333,29 @@ describeWithJsdom('AgentsScene', () => { expect(coreCardSurfaceStyles).not.toContain('width: 360px;'); }); - it('shows Harness as a three-stop rail with a separate creative direction', async () => { + it('presents four Harness strategies as descriptive content between task and result', async () => { const { default: AgentsScene } = await import('./AgentsScene'); await act(async () => { root.render(); }); - const minimal = container.querySelector('[data-testid="agents-harness-minimal"]'); - const balanced = container.querySelector('[data-testid="agents-harness-balanced"]'); - const ultimate = container.querySelector('[data-testid="agents-harness-ultimate"]'); - const creative = container.querySelector('[data-testid="agents-harness-creative"]'); - expect(container.querySelectorAll('.openbitfun-agents-scene__harness-profile')).toHaveLength(3); - expect(container.querySelectorAll('.openbitfun-agents-scene__harness-rail-node')).toHaveLength(3); - expect(container.querySelectorAll('.openbitfun-agents-scene__harness-rail-node.is-default')).toHaveLength(1); - expect(container.querySelector('.openbitfun-agents-scene__harness-presentation')).toBeTruthy(); - expect(container.querySelector('.openbitfun-agents-scene__harness-track')).toBeTruthy(); - expect(container.querySelector('.openbitfun-agents-scene__harness-creative')).toBe(creative); - expect(container.querySelector('.openbitfun-agents-scene__harness-step')).toBeNull(); - expect(container.querySelector('.openbitfun-agents-scene__harness-card')).toBeNull(); - expect(minimal).toBeTruthy(); - expect(minimal?.tagName).toBe('DIV'); - expect(container.querySelector('.openbitfun-agents-scene__harness-step-status')).toBeNull(); - expect(minimal?.dataset.openbitfunState).toBeUndefined(); - expect(ultimate?.dataset.openbitfunState).toBeUndefined(); - expect(minimal?.dataset.harnessGear).toBe('1'); - expect(balanced?.dataset.harnessGear).toBe('2'); - expect(ultimate?.dataset.harnessGear).toBe('3'); - expect(creative?.dataset.harnessGear).toBe('creative'); - expect(minimal?.textContent).toContain('harnessZone.profiles.minimal.purpose'); - expect(minimal?.textContent).not.toContain('harnessZone.connected'); - expect(ultimate?.textContent).not.toContain('harnessZone.comingSoon'); - expect(creative?.querySelector('[data-openbitfun-name="creative"]')).toBeTruthy(); + const presentation = container.querySelector('.openbitfun-agents-scene__harness-presentation'); + expect(presentation?.getAttribute('aria-label')).toBe('harnessZone.flowCaption'); + expect(presentation?.querySelectorAll('[data-openbitfun-component="harness-profile-step"]')).toHaveLength(4); + expect(Array.from(presentation?.querySelectorAll('.openbitfun-agents-scene__harness-endpoint') ?? []) + .map(node => node.textContent)).toEqual(['harnessZone.task', 'harnessZone.result']); + + for (const id of ['minimal', 'balanced', 'ultimate', 'creative']) { + const profile = container.querySelector(`[data-testid="agents-harness-${id}"]`); + expect(profile?.dataset.openbitfunProfile).toBe(id); + expect(profile?.textContent).toContain(`harnessZone.profiles.${id}.name`); + expect(profile?.textContent).toContain(`harnessZone.profiles.${id}.purpose`); + expect(profile?.tagName).toBe('DIV'); + expect(profile?.dataset.openbitfunState).toBeUndefined(); + } + expect(presentation?.querySelector('button, [role="button"], [tabindex]')).toBeNull(); + expect(presentation?.textContent).not.toMatch(/harnessZone\.(connected|comingSoon)/); expect(notificationInfoMock).not.toHaveBeenCalled(); expect(notificationSuccessMock).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index b12a8ba22f..37f719a063 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -15,6 +15,7 @@ import { GalleryZone, } from '@/app/components'; import AgentCard from './components/AgentCard'; +import AgentHarnessOverview from './components/AgentHarnessOverview'; import CoreAgentCard, { type CoreAgentMeta } from './components/CoreAgentCard'; import CreateAgentPage from './components/CreateAgentPage'; import { @@ -63,14 +64,6 @@ import { openEcosystemCompatibility } from '@/app/scenes/ecosystem-compatibility const DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE = '__default_subagent_model__'; -const HARNESS_GEAR_PROFILES = [ - { id: 'minimal', gear: 1 }, - { id: 'balanced', gear: 2 }, - { id: 'ultimate', gear: 3 }, -] as const; - -const HARNESS_DEFAULT_PROFILE_ID = 'balanced'; - type CapabilityTab = 'model' | 'tools' | 'skills' | 'subagents'; type AgentDetailSection = 'basic' | 'behavior' | CapabilityTab; @@ -693,71 +686,7 @@ const AgentsHomeView: React.FC = () => { />
- -
-
-
- - {HARNESS_GEAR_PROFILES.map(({ id }) => ( - - ))} -
-
- {HARNESS_GEAR_PROFILES.map(({ id, gear }) => ( -
- {t(`harnessZone.profiles.${id}.name`)} - {t(`harnessZone.profiles.${id}.purpose`)} -
- ))} -
-
- -
- - - - - - {t('harnessZone.creativeTrack')} - {t('harnessZone.profiles.creative.name')} - - - {t('harnessZone.profiles.creative.purpose')} - - -
-
-
+ ; +}> = { + minimal: { + paths: ['M 0 22 H 320'], + nodes: [[160, 22]], + }, + balanced: { + paths: ['M 0 22 H 320'], + nodes: [[64, 22], [128, 22], [192, 22], [256, 22]], + }, + ultimate: { + paths: [ + 'M 0 22 H 48 C 68 22 70 7 92 7 H 222 C 242 7 245 22 267 22 H 320', + 'M 48 22 C 68 22 70 37 92 37 H 222 C 242 37 245 22 267 22', + ], + nodes: [[48, 22], [116, 7], [174, 7], [124, 37], [192, 37], [267, 22]], + }, + creative: { + paths: ['M 0 22 C 20 22 24 18 48 18 C 88 18 96 34 144 34 C 184 34 192 10 224 10 C 252 10 256 22 280 22 H 320'], + nodes: [[48, 18], [144, 34], [280, 22]], + }, +}; + +function HarnessConnections({ converge = false }: { converge?: boolean }) { + return ( + + ); +} + +function HarnessStrategyRoute({ profile }: { profile: HarnessStrategyId }) { + const route = STRATEGY_ROUTES[profile]; + + return ( + + ); +} + +const AgentHarnessOverview: React.FC = () => { + const { t } = useI18n('scenes/agents'); + + return ( +
+ + {t('harnessZone.subtitle')} + + {t('harnessZone.flowCaption')} + + + )} + > +
+ + {t('harnessZone.task')} + + +
+ {HARNESS_STRATEGIES.map(({ id, icon, gear }) => ( +
+ + + {t(`harnessZone.profiles.${id}.name`)} + + + {t(`harnessZone.profiles.${id}.purpose`)} + + +
+ ))} +
+ + + {t('harnessZone.result')} + +
+
+
+ ); +}; + +export default AgentHarnessOverview; diff --git a/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx b/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx index 36a6ad5eeb..f96be8f000 100644 --- a/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx +++ b/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx @@ -113,7 +113,9 @@ describe('catalog icon consumer integration', () => { it('uses catalog marks in navigation, the creative entry and string-based menus', () => { expect(source('app/components/NavPanel/components/MiniAppEntry.tsx')).toContain(' .notification-center__item-card { + background: var(--openbitfun-color-action-neutral-surface); + } } } - &__group-title { - padding: 6px var(--openbitfun-space-4); - font-size: var(--openbitfun-type-meta-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-muted); - text-transform: uppercase; - letter-spacing: var(--openbitfun-type-modifier-tracking-wider-letter-spacing); - background: var(--openbitfun-color-surface-subtle); - user-select: none; + &__item-card { + background: transparent; + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + &__item-disclosure > [data-openbitfun-part='header'] { + background: transparent; + color: inherit; - &__item { - position: relative; - display: flex; - align-items: flex-start; - gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - cursor: pointer; - transition: - background-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - border-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - transform var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - border-bottom: 1px solid transparent; - - &:hover { - background: var(--openbitfun-color-action-neutral-surface); - - &:not(.is-unread) { - background: var(--openbitfun-color-surface-subtle); - } + > button { + display: grid; + grid-template-columns: var(--openbitfun-layout-disclosure-indicator-size) minmax(0, 1fr); } - &.is-unread { - background: color-mix(in srgb, var(--openbitfun-color-accent-default) 3%, transparent); + [data-openbitfun-part='indicator'], + [data-openbitfun-part='leading'] { + grid-area: 1 / 1; + pointer-events: none; + } - &:hover { - background: color-mix(in srgb, var(--openbitfun-color-accent-default) 6%, transparent); - } + [data-openbitfun-part='indicator'] { + transition: + opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } - &:active { - transform: scale(0.995); + [data-openbitfun-part='leading'] { + opacity: 0; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + [data-openbitfun-part='heading'] { + grid-area: 1 / 2; + } + } - &.is-expanded { - .notification-center__item-title { - white-space: normal; - overflow: visible; - text-overflow: unset; - } + &__item-disclosure > [data-openbitfun-part='content'] > [data-openbitfun-part='content-inner'], + &__item-progress { + padding-inline-start: calc( + var(--openbitfun-layout-disclosure-trigger-padding-inline) + + var(--openbitfun-layout-disclosure-indicator-size) + + var(--openbitfun-layout-disclosure-trigger-gap) + ); + padding-inline-end: var(--openbitfun-layout-disclosure-trigger-padding-inline); + } + + &__item-action { + display: inline-flex; + flex: 0 0 auto; + justify-content: flex-end; + inline-size: var(--openbitfun-control-icon-button-xs-size); + min-inline-size: 0; + margin-inline-start: 0; + overflow: clip; + transition: + inline-size var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), + margin-inline-start var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), + opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - .notification-center__item-message { - -webkit-line-clamp: unset; - line-clamp: unset; - overflow: visible; - } + &:has(:focus-visible) { + overflow: visible; } } - &__item-icon { - flex-shrink: 0; - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); + &__item-status { + display: inline-flex; + color: var(--openbitfun-color-content-secondary); - &--success { - background: var(--openbitfun-color-status-success-surface); + &--success, + &--completed { color: var(--openbitfun-color-status-success-content); } - &--error { - background: var(--openbitfun-color-status-danger-surface); + &--error, + &--failed { color: var(--openbitfun-color-status-danger-content); } &--warning { - background: var(--openbitfun-color-status-warning-surface); - color: var(--openbitfun-color-status-warning-content); - } - - &--info { - background: color-mix(in srgb, var(--openbitfun-color-status-warning-content) 18%, transparent); color: var(--openbitfun-color-status-warning-content); } - - &--completed { - background: var(--openbitfun-color-status-success-surface); - color: var(--openbitfun-color-status-success-content); - } - - &--failed { - background: var(--openbitfun-color-status-danger-surface); - color: var(--openbitfun-color-status-danger-content); + &--active { + color: var(--openbitfun-color-accent-default); } &--cancelled { - background: var(--openbitfun-color-action-neutral-surface); color: var(--openbitfun-color-content-muted); } - - &--active { - background: color-mix(in srgb, var(--openbitfun-color-status-warning-content) 18%, transparent); - color: var(--openbitfun-color-status-warning-content); - } - } - - &__item-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - } - - &__item-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--openbitfun-space-1); - } - - &__item-title { - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-primary); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; } + &__item-time, &__item-percentage { + font-family: var(--openbitfun-type-meta-font-family); font-size: var(--openbitfun-type-meta-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-status-warning-content); + line-height: var(--openbitfun-type-meta-line-height); font-variant-numeric: tabular-nums; - font-family: var(--openbitfun-type-code-md-font-family); - flex-shrink: 0; - } - - &__item-message { - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-secondary); - line-height: var(--openbitfun-type-flow-support-line-height); - margin-bottom: 2px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - word-break: break-word; + color: var(--openbitfun-color-content-muted); + white-space: nowrap; } &__item-time { - font-size: var(--openbitfun-type-micro-font-size); - color: var(--openbitfun-color-content-muted); - font-family: var(--openbitfun-type-code-md-font-family); + margin-inline: var(--openbitfun-space-1); } - &__item-technical-details { - margin-top: var(--openbitfun-space-2); - padding: var(--openbitfun-space-2); - border: 1px solid var(--openbitfun-color-border-default); - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); - cursor: text; + &__item-badge { + flex: 0 0 auto; + inline-size: 4px; + block-size: 4px; + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-accent-default); } - &__item-technical-title { - margin-bottom: 4px; - font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-muted); - text-transform: uppercase; - letter-spacing: var(--openbitfun-type-modifier-tracking-wider-letter-spacing); + &__item-details { + gap: var(--openbitfun-space-3); } - &__item-technical-body { - margin: 0; - max-height: 160px; + &__item-detail-message { + overflow-wrap: anywhere; white-space: pre-wrap; - word-break: break-word; - font-family: var(--openbitfun-type-code-md-font-family); - font-size: var(--openbitfun-type-meta-font-size); - line-height: var(--openbitfun-type-flow-support-line-height); - color: var(--openbitfun-color-content-secondary); } + &__item-progress { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + padding-block: var(--openbitfun-space-1) var(--openbitfun-space-2); + } &__item-progress-bar { - width: 100%; - height: 3px; - background: var(--openbitfun-color-action-neutral-surface); - border-radius: 1px; + flex: 1; + min-inline-size: 0; + block-size: 3px; overflow: hidden; - margin: 4px 0; + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-action-neutral-surface); } &__item-progress-fill { - width: 100%; - height: 100%; - background: linear-gradient(90deg, var(--openbitfun-color-accent-hover), var(--openbitfun-color-accent-secondary)); - border-radius: 2px; + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + background: var(--openbitfun-color-accent-default); transform-origin: left center; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: transform var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); &.is-completed { background: var(--openbitfun-color-status-success-content); @@ -355,286 +243,66 @@ } } - &__item-badge { - flex-shrink: 0; - width: 10px; - height: 10px; - background: var(--openbitfun-color-status-danger-content); - border-radius: 50%; - border: none; - margin-left: auto; - margin-right: var(--openbitfun-space-1); - align-self: center; - } - - &__item-actions { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 4px; - margin-left: var(--openbitfun-space-2); - } - - &__item-expand { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - background: transparent; - border: none; - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-muted); - cursor: pointer; - opacity: 0; - transition: - background-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - transform var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &:hover { - background: var(--openbitfun-color-action-neutral-surface); - color: var(--openbitfun-color-content-primary); - opacity: 1 !important; - } - - &:active { - transform: scale(0.9); - } - } - - &__item-delete { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - background: transparent; - border: none; - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-muted); - cursor: pointer; - opacity: 0; - transition: - background-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - transform var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &:hover { - background: var(--openbitfun-color-action-neutral-surface); - color: var(--openbitfun-color-status-danger-content); - opacity: 1 !important; - } - - &:active { - transform: scale(0.9); - } - } - - &__item:hover &__item-expand, - &__item:hover &__item-delete { - opacity: 1; - } - - - &__item.is-expanded &__item-expand { - opacity: 1; - color: var(--openbitfun-color-content-primary); - } - - - - &__active-section { - padding: var(--openbitfun-space-3) 0; - border-bottom: 1px solid var(--openbitfun-color-border-default); - background: color-mix(in srgb, var(--openbitfun-color-accent-hover) 2%, transparent); - } - - &__active-section-title { - padding: 6px var(--openbitfun-space-4); + &__item-technical-title { + margin-block-end: var(--openbitfun-space-2); font-size: var(--openbitfun-type-meta-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-accent-default); - text-transform: uppercase; - letter-spacing: var(--openbitfun-type-modifier-tracking-wider-letter-spacing); - user-select: none; - } - - &__active-section-list { - display: flex; - flex-direction: column; - gap: 2px; + color: var(--openbitfun-color-content-muted); } - - &__active-task-item { - display: flex; - align-items: flex-start; - gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - transition: background var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &:hover { - background: color-mix(in srgb, var(--openbitfun-color-accent-hover) 5%, transparent); + &__item-technical-body { + max-block-size: 160px; + + pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--openbitfun-type-code-md-font-family); + font-size: var(--openbitfun-type-meta-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); } } - &__active-task-icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - color: var(--openbitfun-color-accent-default); - margin-top: 1px; - } - &__spinner { animation: openbitfun-notification-center-spin 1s linear infinite; } - - &__active-task-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - } - - &__active-task-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--openbitfun-space-1); - } - - &__active-task-title { - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - } - - &__active-task-progress-text { - font-size: var(--openbitfun-type-meta-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-accent-default); - font-variant-numeric: tabular-nums; - font-family: var(--openbitfun-type-code-md-font-family); - flex-shrink: 0; - } - - &__active-task-message { - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__active-task-progress-bar { - width: 100%; - height: 3px; - background: var(--openbitfun-color-action-neutral-surface); - border-radius: 1px; - overflow: hidden; - margin-top: 4px; - } - - &__active-task-progress-fill { - width: 100%; - height: 100%; - background: linear-gradient(90deg, var(--openbitfun-color-accent-hover), var(--openbitfun-color-accent-secondary)); - border-radius: 1px; - transform-origin: left center; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - - - &__active-loading-item { - display: flex; - align-items: center; - gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - transition: background var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &:hover { - background: color-mix(in srgb, var(--openbitfun-color-accent-hover) 5%, transparent); - } - } - - &__active-loading-icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - color: var(--openbitfun-color-accent-default); - } - - &__active-loading-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - } - - &__active-loading-title { - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__active-loading-message { - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } } - @keyframes openbitfun-notification-center-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + to { transform: rotate(360deg); } } +@media (hover: hover) and (pointer: fine) { + .notification-center__item:not(:hover):not(:has(:focus-visible)) { + .notification-center__item-action { + inline-size: 0; + margin-inline-start: calc(-1 * var(--openbitfun-layout-disclosure-actions-gap)); + opacity: 0; + pointer-events: none; + } + + > .notification-center__item-card > .notification-center__item-disclosure[data-open='false'] > [data-openbitfun-part='header'] { + [data-openbitfun-part='indicator'] { + opacity: 0; + } -@media (max-width: 768px) { - .notification-center { - height: 78vh; - max-height: calc(100vh - 80px); + [data-openbitfun-part='leading'] { + opacity: 1; + } + } } } @media (prefers-reduced-motion: reduce) { .notification-center__spinner { animation: none; - transform: none; } + .notification-center__item-card, .notification-center__item-progress-fill, - .notification-center__active-task-progress-fill { + .notification-center__item-disclosure > [data-openbitfun-part='header'] [data-openbitfun-part='indicator'], + .notification-center__item-disclosure > [data-openbitfun-part='header'] [data-openbitfun-part='leading'], + .notification-center__item-action { transition: none; } } diff --git a/src/web-ui/src/shared/notification-system/components/NotificationCenter.test.tsx b/src/web-ui/src/shared/notification-system/components/NotificationCenter.test.tsx index 8faf86bd85..3fd0530f25 100644 --- a/src/web-ui/src/shared/notification-system/components/NotificationCenter.test.tsx +++ b/src/web-ui/src/shared/notification-system/components/NotificationCenter.test.tsx @@ -9,17 +9,36 @@ const service = vi.hoisted(() => ({ toggleCenter: vi.fn(), markAllAsRead: vi.fn(), clearHistory: vi.fn(), + deleteFromHistory: vi.fn(), + markAsRead: vi.fn(), +})); + +const notificationState = vi.hoisted(() => ({ + centerOpen: true, + notificationHistory: [{ + id: 'history-1', + type: 'info' as const, + variant: 'toast' as const, + title: 'Notice', + message: 'Notification message', + timestamp: Date.now(), + read: false, + }], + activeNotifications: [], + unreadCount: 1, + config: {}, })); vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), + useI18n: () => ({ + t: (key: string) => key, + formatDate: () => '12:00', + formatNumber: (value: number) => String(value), + }), })); vi.mock('../hooks/useNotificationState', () => ({ - useCenterOpen: () => true, - useNotificationHistory: () => [], - useAllProgressNotifications: () => [], - useAllLoadingNotifications: () => [], + useNotificationState: () => notificationState, })); vi.mock('../services/NotificationService', () => ({ notificationService: service })); diff --git a/src/web-ui/src/shared/notification-system/components/NotificationCenter.tsx b/src/web-ui/src/shared/notification-system/components/NotificationCenter.tsx index 2a35316d6c..fb791fba9c 100644 --- a/src/web-ui/src/shared/notification-system/components/NotificationCenter.tsx +++ b/src/web-ui/src/shared/notification-system/components/NotificationCenter.tsx @@ -1,364 +1,210 @@ - - -import React, { useState, useMemo } from 'react'; -import { CheckCheck, Loader2 } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { AlertTriangle, Ban, CheckCheck, Loader2, XCircle } from 'lucide-react'; import { + Card, + CardBody, + Dialog, + DialogBody, + DialogClose, + DialogHeader, + DialogHeaderActions, + DialogHeading, + DialogTitle, + Disclosure, Icon, IconButton, + OverflowText, ScrollArea, SearchField, - Dialog, - DialogBody, } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; -import { useNotificationHistory, useCenterOpen, useAllProgressNotifications, useAllLoadingNotifications } from '../hooks/useNotificationState'; +import { useNotificationState } from '../hooks/useNotificationState'; import { notificationService } from '../services/NotificationService'; -import { NotificationFilter, NotificationRecord, Notification } from '../types'; +import type { Notification } from '../types'; import './NotificationCenter.scss'; + export const NotificationCenter: React.FC = () => { - const isOpen = useCenterOpen(); - const history = useNotificationHistory(); - const allProgressNotifications = useAllProgressNotifications(); - const allLoadingNotifications = useAllLoadingNotifications(); - const { t, formatDate } = useI18n(['components', 'common', 'errors']); - const [filter, setFilter] = useState('all'); + const { centerOpen: isOpen, notificationHistory: history, activeNotifications } = useNotificationState(); + const { t, formatDate, formatNumber } = useI18n(['components', 'common', 'errors']); const [searchQuery, setSearchQuery] = useState(''); const [expandedIds, setExpandedIds] = useState>(new Set()); - const activeTaskNotifications = useMemo(() => { - return [...allProgressNotifications, ...allLoadingNotifications]; - }, [allProgressNotifications, allLoadingNotifications]); - - - const handleClose = React.useCallback(() => { - notificationService.toggleCenter(false); - }, []); - - - const handleMarkAllRead = () => { - notificationService.markAllAsRead(); - }; - - - const handleClearAll = () => { - notificationService.clearHistory(); - }; - - - const handleDeleteNotification = (e: React.MouseEvent, notificationId: string) => { - e.stopPropagation(); - notificationService.deleteFromHistory(notificationId); - }; - - - const handleNotificationClick = (notification: NotificationRecord) => { - - setExpandedIds(prev => { - const newSet = new Set(prev); - if (newSet.has(notification.id)) { - newSet.delete(notification.id); - } else { - newSet.add(notification.id); - } - return newSet; - }); - - - if (!notification.read) { - notificationService.markAsRead(notification.id); - } - - - if (notification.metadata?.onClick) { - notification.metadata.onClick(); - } + const defaultTitles = { + success: t('common:status.success'), + error: t('common:status.error'), + warning: t('common:status.warning'), + info: t('common:status.info'), }; - - const filteredHistory = useMemo(() => { - let filtered = history; - - - - filtered = filtered.filter(n => { - if (n.variant === 'progress' || n.variant === 'loading') { - - return n.status === 'completed' || n.status === 'failed' || n.status === 'cancelled'; + const notifications = useMemo(() => { + const activeTasks = activeNotifications.filter(isActiveTask); + const activeTaskIds = new Set(activeTasks.map(notification => notification.id)); + const completedHistory = history.filter(notification => { + if (activeTaskIds.has(notification.id)) return false; + if (notification.variant === 'progress' || notification.variant === 'loading') { + return notification.status === 'completed' + || notification.status === 'failed' + || notification.status === 'cancelled'; } - return true; + return true; }); + const query = searchQuery.trim().toLowerCase(); - - if (filter !== 'all') { - filtered = filtered.filter(n => n.type === filter); - } - - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter(n => - n.title.toLowerCase().includes(query) || - n.message.toLowerCase().includes(query) - ); - } - - return filtered; - }, [history, filter, searchQuery]); - - - const groupedHistory = useMemo(() => { - const now = Date.now(); - const today = new Date(now).setHours(0, 0, 0, 0); - const yesterday = today - 86400000; - - const groups = { - today: [] as NotificationRecord[], - yesterday: [] as NotificationRecord[], - earlier: [] as NotificationRecord[] - }; - - filteredHistory.forEach(notification => { - const notificationDate = new Date(notification.timestamp).setHours(0, 0, 0, 0); - - if (notificationDate === today) { - groups.today.push(notification); - } else if (notificationDate === yesterday) { - groups.yesterday.push(notification); - } else { - groups.earlier.push(notification); - } - }); + return [...activeTasks, ...completedHistory] + .filter(notification => !query + || notification.title.toLowerCase().includes(query) + || notification.message.toLowerCase().includes(query) + || notification.progressText?.toLowerCase().includes(query)) + .sort((left, right) => right.timestamp - left.timestamp); + }, [activeNotifications, history, searchQuery]); - return groups; - }, [filteredHistory]); - - - const formatTime = (timestamp: number) => { - return formatDate(timestamp, { hour: '2-digit', minute: '2-digit' }); - }; - - - const getIcon = (type: string, status?: string) => { - - if (status === 'completed') { - return '✓'; - } - if (status === 'failed') { - return '✕'; - } - if (status === 'cancelled') { - return '⊘'; - } - - - switch (type) { - case 'success': - return '✓'; - case 'error': - return '✕'; - case 'warning': - return '⚠'; - case 'info': - default: - return 'ℹ'; - } + const handleClose = () => { + notificationService.toggleCenter(false); }; - const getTechnicalDetails = (notification: NotificationRecord | Notification): string | null => { - const metadata = notification.metadata; - const aiError = metadata?.aiError; - const diagnostics = normalizeMetadataString(aiError?.diagnostics ?? metadata?.diagnostics); - const rawError = normalizeMetadataString(aiError?.rawError ?? metadata?.rawError); + const handleNotificationOpenChange = (notification: Notification, open: boolean) => { + setExpandedIds(previous => { + const next = new Set(previous); + if (open) next.add(notification.id); + else next.delete(notification.id); + return next; + }); - if (diagnostics && rawError && !diagnostics.includes(rawError)) { - return `${diagnostics}\nraw_error=${rawError}`; + if (!isActiveTask(notification)) { + if (!notification.read) notificationService.markAsRead(notification.id); + notification.metadata?.onClick?.(); } - - return diagnostics || (rawError ? `raw_error=${rawError}` : null); }; - - const renderActiveTaskItem = (notification: Notification) => { - const isProgress = notification.variant === 'progress'; - const isLoading = notification.variant === 'loading'; - - - const getProgressInfo = () => { - if (isLoading) { - return null; - } - - if (isProgress) { - const mode = notification.progressMode || (notification.textOnly ? 'text-only' : 'percentage'); - if (mode === 'text-only') return null; - - if (mode === 'fraction' && notification.current !== undefined && notification.total !== undefined) { - return `${notification.current}/${notification.total}`; - } - - if (mode === 'percentage' && notification.progress !== undefined) { - return `${Math.round(notification.progress)}%`; - } - } - - return null; - }; - - const progressInfo = getProgressInfo(); - - return ( -
-
- -
-
-
-
{notification.title}
- {progressInfo && ( -
{progressInfo}
- )} -
-
- {isProgress && notification.progressText ? notification.progressText : (notification.messageNode ?? notification.message)} -
- - {isProgress && (() => { - const mode = notification.progressMode || (notification.textOnly ? 'text-only' : 'percentage'); - if (mode === 'text-only') return null; - - return ( -
-
-
- ); - })()} -
-
- ); + const formatTime = (timestamp: number) => { + const now = new Date(); + const date = new Date(timestamp); + const isToday = date.getFullYear() === now.getFullYear() + && date.getMonth() === now.getMonth() + && date.getDate() === now.getDate(); + + return formatDate(timestamp, { + ...(isToday ? {} : { month: '2-digit', day: '2-digit' }), + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + hour: '2-digit', + minute: '2-digit', + }); }; - - const renderNotificationItem = (notification: NotificationRecord) => { + const renderNotificationItem = (notification: Notification) => { const isProgress = notification.variant === 'progress'; - const isLoading = notification.variant === 'loading'; - const iconClass = (isProgress || isLoading) && notification.status - ? `notification-center__item-icon--${notification.status}` - : `notification-center__item-icon--${notification.type}`; - - - const now = Date.now(); - const today = new Date(now).setHours(0, 0, 0, 0); - const yesterday = today - 86400000; - const notificationDate = new Date(notification.timestamp).setHours(0, 0, 0, 0); - - const timeDisplay = notificationDate < yesterday - ? formatDate(notification.timestamp, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) - : formatTime(notification.timestamp); - + const isTask = isProgress || notification.variant === 'loading'; + const isActive = isActiveTask(notification); + const isUnread = !isActive && !notification.read; const isExpanded = expandedIds.has(notification.id); + const status = isTask && notification.status ? notification.status : notification.type; const technicalDetails = getTechnicalDetails(notification); + const usesMessageAsTitle = !isTask + && notification.messageNode == null + && Boolean(notification.message.trim()) + && notification.title.trim() === defaultTitles[notification.type]; + const title = usesMessageAsTitle ? notification.message : notification.title; + const messageText = isProgress && notification.progressText + ? notification.progressText + : notification.message; + const message = isProgress && notification.progressText + ? notification.progressText + : (notification.messageNode ?? notification.message); + const mode = notification.progressMode || (notification.textOnly ? 'text-only' : 'percentage'); + const showProgress = isProgress && mode !== 'text-only'; + const progress = Math.max(0, Math.min(100, notification.progress || 0)); + const progressInfo = !showProgress ? null + : mode === 'fraction' && notification.current !== undefined && notification.total !== undefined + ? formatNumber(notification.current) + '/' + formatNumber(notification.total) + : notification.progress !== undefined + ? formatNumber(progress / 100, { style: 'percent', maximumFractionDigits: 0 }) + : null; return ( -
handleNotificationClick(notification)} + className="notification-center__item" data-notification-id={notification.id} data-notification-title={notification.title} data-notification-message={notification.message} data-notification-diagnostics={technicalDetails ?? undefined} - data-context-type="notification" + data-context-type={isActive ? undefined : 'notification'} data-openbitfun-component="notification" data-openbitfun-part="centerItem" - data-openbitfun-state={`${!notification.read ? 'unread ' : ''}${isExpanded ? 'expanded' : ''}`.trim() || undefined} + data-openbitfun-state={[isUnread && 'unread', isExpanded && 'expanded'].filter(Boolean).join(' ') || undefined} > -
- {getIcon(notification.type, notification.status)} -
-
-
-
{notification.title}
- - {isProgress && (() => { - const mode = notification.progressMode || (notification.textOnly ? 'text-only' : 'percentage'); - if (mode === 'text-only') return null; - - if (mode === 'fraction' && notification.current !== undefined && notification.total !== undefined) { - return
{notification.current}/{notification.total}
; - } - - if (mode === 'percentage' && notification.progress !== undefined) { - return
{Math.round(notification.progress)}%
; - } - - return null; - })()} -
-
- {(isProgress && notification.progressText) ? notification.progressText : (notification.messageNode ?? notification.message)} -
- - {isProgress && (() => { - const mode = notification.progressMode || (notification.textOnly ? 'text-only' : 'percentage'); - if (mode === 'text-only') return null; - - return ( -
+ + handleNotificationOpenChange(notification, open)} + summary={{title}} + description={!usesMessageAsTitle && messageText + ? {messageText} + : undefined} + leading={ + + {getNotificationIcon(notification)} + + } + actions={ + <> + {isUnread && + {showProgress && ( +
+
- ); - })()} -
{timeDisplay}
- {isExpanded && technicalDetails && ( -
event.stopPropagation()} - > -
- {t('errors:boundary.technicalDetails')} -
- -
{technicalDetails}
-
+ {progressInfo && {progressInfo}}
)} -
- {!notification.read &&
} -
- - -
-
+ + ); }; @@ -366,140 +212,114 @@ export const NotificationCenter: React.FC = () => { { if (!nextOpen) handleClose(); }} - size="lg" + size="md" aria-label={t('components:notificationCenter.title')} > - -
- +
-

{t('components:notificationCenter.title')}

-
- } - size="sm" - onClick={handleMarkAllRead} - title={t('components:notificationCenter.actions.markAllRead')} - aria-label={t('components:notificationCenter.actions.markAllRead')} - /> - } - size="sm" - onClick={handleClearAll} - title={t('components:notificationCenter.actions.clearAll')} - aria-label={t('components:notificationCenter.actions.clearAll')} - /> - } - onClick={handleClose} - size="sm" - title={t('common:actions.close')} - aria-label={t('common:actions.close')} - data-testid="notification-center-close-btn" - data-openbitfun-component="notification" - data-openbitfun-part="centerClose" + + + {t('components:notificationCenter.title')} + + + } + onClick={() => notificationService.markAllAsRead()} + disabled={!history.some(notification => !notification.read)} + title={t('components:notificationCenter.actions.markAllRead')} + aria-label={t('components:notificationCenter.actions.markAllRead')} + /> + } + onClick={() => notificationService.clearHistory()} + disabled={history.length === 0} + title={t('components:notificationCenter.actions.clearAll')} + aria-label={t('components:notificationCenter.actions.clearAll')} + /> + + + + + +
+ +
+ } + value={searchQuery} + onValueChange={setSearchQuery} + clearLabel={t('components:search.clear')} + onClear={searchQuery ? () => setSearchQuery('') : undefined} + size="md" />
-
- - -
- } - value={searchQuery} - onValueChange={(val) => setSearchQuery(val)} - clearLabel={t('components:search.clear')} - onClear={searchQuery ? () => setSearchQuery('') : undefined} - size="md" - /> -
- - -
- - - - -
- - - - - {activeTaskNotifications.length > 0 && ( -
-
- {t('components:notificationCenter.activeTasks.title', { count: activeTaskNotifications.length })} -
-
- {activeTaskNotifications.map(renderActiveTaskItem)} -
-
- )} - - {filteredHistory.length === 0 && activeTaskNotifications.length === 0 ? ( -
-
-
- {searchQuery ? t('components:notificationCenter.empty.noMatches') : t('components:notificationCenter.empty.noNotifications')} -
-
- ) : ( - <> - - {groupedHistory.today.length > 0 && ( -
-
{t('common:time.today')}
- {groupedHistory.today.map(renderNotificationItem)} + + {notifications.length === 0 ? ( +
+ - )} - - - {groupedHistory.yesterday.length > 0 && ( -
-
{t('common:time.yesterday')}
- {groupedHistory.yesterday.map(renderNotificationItem)} +
+ {searchQuery.trim() + ? t('components:notificationCenter.empty.noMatches') + : t('components:notificationCenter.empty.noNotifications')}
- )} - - - {groupedHistory.earlier.length > 0 && ( -
-
{t('components:notificationCenter.groups.earlier')}
- {groupedHistory.earlier.map(renderNotificationItem)} -
- )} - - )} - +
+ ) : ( +
    + {notifications.map(renderNotificationItem)} +
+ )} + +
-
); }; +function isActiveTask(notification: Notification): boolean { + return (notification.variant === 'progress' || notification.variant === 'loading') + && notification.status === 'active'; +} + +function getNotificationIcon(notification: Notification) { + if (isActiveTask(notification)) return ; + if (notification.status === 'completed') return ; + if (notification.status === 'failed') return ; + if (notification.status === 'cancelled') return ; + + switch (notification.type) { + case 'success': return ; + case 'error': return ; + case 'warning': return ; + default: return ; + } +} + +function getTechnicalDetails(notification: Notification): string | null { + const metadata = notification.metadata; + const aiError = metadata?.aiError; + const diagnostics = normalizeMetadataString(aiError?.diagnostics ?? metadata?.diagnostics); + const rawError = normalizeMetadataString(aiError?.rawError ?? metadata?.rawError); + + if (diagnostics && rawError && !diagnostics.includes(rawError)) { + return diagnostics + '\nraw_error=' + rawError; + } + + return diagnostics || (rawError ? 'raw_error=' + rawError : null); +} + function normalizeMetadataString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; }