diff --git a/frontend/src/ui/SkyWidgetFrame.test.ts b/frontend/src/ui/SkyWidgetFrame.test.ts new file mode 100644 index 0000000..63f7a79 --- /dev/null +++ b/frontend/src/ui/SkyWidgetFrame.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' + +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { describe, expect, it } from 'vitest' + +import SkyWidgetFrame from '@/ui/SkyWidgetFrame.vue' + +const foundationStyles = readFileSync( + new URL('./foundation.css', import.meta.url), + 'utf8', +) + +describe('SkyWidgetFrame', () => { + it('reserves a separate app-style label row below the widget surface', async () => { + const html = await renderToString( + createSSRApp(SkyWidgetFrame, { label: 'Weather', size: 'small' }), + ) + + expect(html).toContain('sky-widget-frame--small') + expect(html).toContain('sky-widget-frame__surface') + expect(html).toContain('sky-widget-frame__label') + expect(html).toContain('Weather') + expect(foundationStyles).toContain('var(--sky-widget-label-height);') + expect(foundationStyles).toContain('var(--sky-widget-label-gap)') + }) + + it('keeps gallery previews unlabelled when requested', async () => { + const html = await renderToString( + createSSRApp(SkyWidgetFrame, { + label: 'Music', + showLabel: false, + size: 'medium', + }), + ) + + expect(html).toContain('sky-widget-frame--unlabelled') + expect(html).not.toContain('sky-widget-frame__label') + }) +}) diff --git a/frontend/src/ui/SkyWidgetFrame.vue b/frontend/src/ui/SkyWidgetFrame.vue new file mode 100644 index 0000000..17cb1f9 --- /dev/null +++ b/frontend/src/ui/SkyWidgetFrame.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/ui/foundation.css b/frontend/src/ui/foundation.css index cd568ff..d136e5a 100644 --- a/frontend/src/ui/foundation.css +++ b/frontend/src/ui/foundation.css @@ -242,6 +242,47 @@ cursor: grabbing; } +.sky-widget-frame { + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: + minmax(0, 1fr) + var(--sky-widget-label-height); + gap: var(--sky-widget-label-gap); +} + +.sky-widget-frame--unlabelled { + grid-template-rows: minmax(0, 1fr); + gap: 0; +} + +.sky-widget-frame__surface { + min-width: 0; + min-height: 0; +} + +.sky-widget-frame__label { + min-width: 0; + height: var(--sky-widget-label-height); + overflow: hidden; + color: var(--sky-widget-label-color, #fff); + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif; + font-size: 11.5px; + font-weight: 500; + letter-spacing: -0.15px; + line-height: var(--sky-widget-label-height); + text-align: center; + text-overflow: ellipsis; + text-shadow: 0 1px 4px rgb(0 0 0 / 72%); + white-space: nowrap; + pointer-events: none; +} + .sky-section { min-width: 0; margin: 0 0 var(--sky-space-4); diff --git a/frontend/src/ui/index.ts b/frontend/src/ui/index.ts index 9155b50..6aefd8c 100644 --- a/frontend/src/ui/index.ts +++ b/frontend/src/ui/index.ts @@ -7,6 +7,7 @@ export { default as SkyScrollArea } from './SkyScrollArea.vue' export { default as SkyScrollRail } from './SkyScrollRail.vue' export { default as SkySection } from './SkySection.vue' export { default as SkyTabBar } from './SkyTabBar.vue' +export { default as SkyWidgetFrame } from './SkyWidgetFrame.vue' export * from './controls' export * from './overlays' export * from './settings' diff --git a/frontend/src/ui/tokens.css b/frontend/src/ui/tokens.css index 41bd736..7d4c16e 100644 --- a/frontend/src/ui/tokens.css +++ b/frontend/src/ui/tokens.css @@ -20,6 +20,11 @@ --sky-font-body: 15px; --sky-font-title: 17px; --sky-font-large-title: 28px; + --sky-widget-label-gap: 5px; + --sky-widget-label-height: 15px; + --sky-widget-radius-small: 23px; + --sky-widget-radius-medium: 25px; + --sky-widget-radius-large: 28px; } .sky-app-page {