ENH - merge latest dev into weather app

Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
Type
2026-08-17 11:38:13 +02:00
152 changed files with 15805 additions and 4315 deletions
+9 -4
View File
@@ -89,10 +89,15 @@ function updateHighlightPosition(event: PointerEvent): void {
return
}
const bounds = root.value?.getBoundingClientRect()
if (!bounds) return
highlightX.value = `${Math.max(0, Math.min(bounds.width, event.clientX - bounds.left))}px`
highlightY.value = `${Math.max(0, Math.min(bounds.height, event.clientY - bounds.top))}px`
const element = root.value
const bounds = element?.getBoundingClientRect()
if (!element || !bounds) return
const scaleX = bounds.width > 0 ? element.offsetWidth / bounds.width : 1
const scaleY = bounds.height > 0 ? element.offsetHeight / bounds.height : 1
const localX = (event.clientX - bounds.left) * scaleX
const localY = (event.clientY - bounds.top) * scaleY
highlightX.value = `${Math.max(0, Math.min(element.offsetWidth, localX))}px`
highlightY.value = `${Math.max(0, Math.min(element.offsetHeight, localY))}px`
highlightVisible.value = true
}
@@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./SkyGlass.vue', import.meta.url), 'utf8')
describe('SkyGlass pointer geometry contract', () => {
it('maps viewport pointer coordinates into the scaled element layout', () => {
expect(source).toContain('element.offsetWidth / bounds.width')
expect(source).toContain('element.offsetHeight / bounds.height')
expect(source).toContain('(event.clientX - bounds.left) * scaleX')
expect(source).toContain('(event.clientY - bounds.top) * scaleY')
expect(source).not.toContain(
'Math.min(bounds.width, event.clientX - bounds.left)',
)
})
})
+8
View File
@@ -244,6 +244,10 @@
background: rgba(120, 120, 128, 0.38);
}
.sky-sheet [data-sky-sheet-drag-handle] {
touch-action: none;
}
.sky-action-sheet__panel {
max-width: 448px;
margin: 0 auto;
@@ -378,6 +382,10 @@
flex: 1 1 0;
}
.sky-dialog-button.sky-button {
color: #fff;
}
.sky-dialog-button:disabled {
background: var(--sky-pressed);
color: var(--sky-subtle);
@@ -19,6 +19,10 @@ async function renderDialogButton(
describe('SkyDialogButton', () => {
it('uses the Konsta iOS tonal button for a regular action', async () => {
const html = await renderDialogButton()
const overlays = readFileSync(
new URL('../overlays.css', import.meta.url),
'utf8',
)
expect(html).toContain('<button')
expect(html).toContain('type="button"')
@@ -28,6 +32,9 @@ describe('SkyDialogButton', () => {
expect(html).toContain('sky-button--rounded')
expect(html).toContain('sky-button--tonal')
expect(html).not.toContain('sky-dialog-button--strong')
expect(overlays).toMatch(
/\.sky-dialog-button\.sky-button\s*\{\s*color:\s*#fff;/,
)
})
it('uses the Konsta iOS filled button for a strong action', async () => {
+4 -3
View File
@@ -23,6 +23,9 @@ describe('SkyDropdown', () => {
it('supports checked, submenu, destructive, disabled, and divided items', () => {
expect(component).toContain("'menuitemradio'")
expect(component).toContain(':aria-checked=')
expect(component).toContain("section.group ? 'group' : 'presentation'")
expect(component).toContain('section.group ? section.label : undefined')
expect(component).toContain('groupLabel?: string')
expect(component).toContain(':aria-haspopup=')
expect(component).toContain('item.destructive')
expect(component).toContain('item.disabled')
@@ -35,9 +38,7 @@ describe('SkyDropdown', () => {
expect(overlays).toMatch(
/\.sky-dropdown__item\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)/s,
)
expect(overlays).toMatch(
/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s,
)
expect(overlays).toMatch(/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s)
expect(overlays).toMatch(
/\.sky-dropdown__item\s*\{[^}]*border-radius:\s*12px;/s,
)
+64 -27
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Check, ChevronRight } from 'lucide-vue-next'
import { computed } from 'vue'
import SkyPopover from './SkyPopover.vue'
@@ -9,13 +10,15 @@ interface SkyDropdownItem {
checked?: boolean
destructive?: boolean
disabled?: boolean
group?: string
groupLabel?: string
id: string
label: string
separatorBefore?: boolean
submenu?: boolean
}
withDefaults(
const props = withDefaults(
defineProps<{
items: readonly SkyDropdownItem[]
label: string
@@ -34,6 +37,33 @@ const emit = defineEmits<{
positionerror: [reason: string]
select: [id: string, event: MouseEvent]
}>()
const menuSections = computed(() => {
const sections: Array<{
group: string | null
items: SkyDropdownItem[]
key: string
label?: string
}> = []
props.items.forEach((item) => {
const group = item.group ?? null
const previous = sections[sections.length - 1]
if (!previous || previous.group !== group) {
sections.push({
group,
items: [item],
key: `${group ?? 'items'}:${sections.length}`,
label: item.groupLabel,
})
return
}
previous.items.push(item)
})
return sections
})
</script>
<template>
@@ -50,33 +80,40 @@ const emit = defineEmits<{
@positionerror="emit('positionerror', $event)"
>
<div class="sky-dropdown__menu">
<button
v-for="item in items"
:key="item.id"
class="sky-dropdown__item"
:class="{
'sky-dropdown__item--destructive': item.destructive,
'sky-dropdown__item--separator': item.separatorBefore,
}"
:aria-checked="item.checked === undefined ? undefined : item.checked"
:aria-haspopup="item.submenu ? 'menu' : undefined"
:disabled="item.disabled"
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
type="button"
@click="emit('select', item.id, $event)"
<div
v-for="section in menuSections"
:key="section.key"
:aria-label="section.group ? section.label : undefined"
:role="section.group ? 'group' : 'presentation'"
>
<span class="sky-dropdown__indicator" aria-hidden="true">
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
</span>
<span class="sky-dropdown__label">{{ item.label }}</span>
<ChevronRight
v-if="item.submenu"
class="sky-dropdown__chevron"
:size="21"
:stroke-width="2.5"
aria-hidden="true"
/>
</button>
<button
v-for="item in section.items"
:key="item.id"
class="sky-dropdown__item"
:class="{
'sky-dropdown__item--destructive': item.destructive,
'sky-dropdown__item--separator': item.separatorBefore,
}"
:aria-checked="item.checked === undefined ? undefined : item.checked"
:aria-haspopup="item.submenu ? 'menu' : undefined"
:disabled="item.disabled"
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
type="button"
@click="emit('select', item.id, $event)"
>
<span class="sky-dropdown__indicator" aria-hidden="true">
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
</span>
<span class="sky-dropdown__label">{{ item.label }}</span>
<ChevronRight
v-if="item.submenu"
class="sky-dropdown__chevron"
:size="21"
:stroke-width="2.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</SkyPopover>
</template>
+11
View File
@@ -24,10 +24,16 @@ describe('SkySheet', () => {
swipeToClose: true,
})
const ordinary = await renderSheet({ opened: true })
const hiddenGrabber = await renderSheet({
opened: true,
showGrabber: false,
swipeToClose: true,
})
expect(swipeable).toContain('class="sky-sheet__grabber"')
expect(swipeable).toContain('Sheet content')
expect(ordinary).not.toContain('sky-sheet__grabber')
expect(hiddenGrabber).not.toContain('sky-sheet__grabber')
})
it('can expose the drag handle as an accessible close button', async () => {
@@ -56,11 +62,16 @@ describe('SkySheet', () => {
expect(source).toContain('swipeclose: [event: PointerEvent]')
expect(source).toContain('grabberclick: [event: MouseEvent]')
expect(source).toContain('setPointerCapture(event.pointerId)')
expect(source).toContain("target.closest('[data-sky-sheet-drag-handle]')")
expect(source).toContain('@pointerdown="startDrag"')
expect(source).toContain('dragOffset.value >= closeThreshold')
expect(source).toContain("emit('swipeclose', event)")
expect(overlays).toMatch(
/\.sky-sheet__grabber\s*\{[^}]*touch-action:\s*none;/s,
)
expect(overlays).toMatch(
/\.sky-sheet \[data-sky-sheet-drag-handle\]\s*\{[^}]*touch-action:\s*none;/s,
)
expect(overlays).toMatch(
/\.sky-sheet__panel--settling\s*\{[^}]*transition:\s*transform 220ms/s,
)
+25 -6
View File
@@ -16,6 +16,7 @@ const props = withDefaults(
grabberLabel?: string
opened: boolean
role?: 'alertdialog' | 'dialog' | 'none' | 'presentation'
showGrabber?: boolean
swipeToClose?: boolean
tabindex?: number | string
}>(),
@@ -25,6 +26,7 @@ const props = withDefaults(
component: 'div',
grabberClickable: false,
grabberLabel: '',
showGrabber: true,
tabindex: -1,
},
)
@@ -76,9 +78,26 @@ function settleDrag(): void {
}
function startDrag(event: PointerEvent): void {
const target = event.target
const grabber =
target instanceof Element
? target.closest('.sky-sheet__grabber')
: undefined
const extendedHandle =
target instanceof Element
? target.closest('[data-sky-sheet-drag-handle]')
: undefined
const interactiveTarget =
target instanceof Element
? target.closest(
'button, a, input, select, textarea, [role="button"], [contenteditable="true"]',
)
: undefined
if (
!props.swipeToClose ||
!event.isPrimary ||
(!grabber && (!extendedHandle || interactiveTarget)) ||
(event.pointerType === 'mouse' && event.button !== 0)
) {
return
@@ -204,20 +223,20 @@ useOverlayFocusTrap({
"
:aria-describedby="effectiveRole ? ariaDescribedby : undefined"
:tabindex="tabindex"
@lostpointercapture="finishDrag($event, true)"
@pointercancel="finishDrag($event, true)"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="finishDrag($event)"
>
<component
:is="grabberClickable ? 'button' : 'div'"
v-if="swipeToClose"
v-if="swipeToClose && showGrabber"
class="sky-sheet__grabber"
:type="grabberClickable ? 'button' : undefined"
:aria-hidden="grabberClickable ? undefined : true"
:aria-label="grabberClickable ? grabberLabel : undefined"
@click="grabberClickable && emit('grabberclick', $event)"
@lostpointercapture="finishDrag($event, true)"
@pointercancel="finishDrag($event, true)"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="finishDrag($event)"
></component>
<slot />
</component>
+66 -4
View File
@@ -190,19 +190,81 @@ label.sky-settings-row__title {
}
.sky-settings-range-row__frame {
padding-top: var(--sky-space-1);
padding-bottom: var(--sky-space-1);
min-height: 88px;
padding-top: 12px;
padding-bottom: 14px;
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.sky-settings-group--compact .sky-settings-row__frame,
.sky-settings-group--compact .sky-settings-range-row__frame {
min-height: 48px;
min-height: 76px;
padding-top: 6px;
padding-bottom: 6px;
}
.sky-settings-range-row .sky-range {
.sky-settings-range-row__header,
.sky-settings-range-row__control {
min-width: 0;
display: flex;
align-items: center;
}
.sky-settings-range-row__header {
justify-content: space-between;
gap: 16px;
}
.sky-settings-range-row__title,
.sky-settings-range-row__value {
font-size: 17px;
line-height: 22px;
}
.sky-settings-range-row__title {
min-width: 0;
color: var(--sky-text);
overflow-wrap: anywhere;
}
.sky-settings-range-row__value {
flex: none;
color: var(--sky-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sky-settings-range-row__control {
min-height: 28px;
gap: 12px;
}
.sky-settings-range-row__control .sky-range {
width: 100%;
flex: 1 1 auto;
}
.sky-settings-range-row__endpoint {
width: 24px;
height: 28px;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
color: var(--sky-muted);
}
.sky-settings-range-row__endpoint > svg {
width: 22px;
height: 22px;
display: block;
}
.sky-settings-range-row__endpoint--leading > svg {
width: 17px;
height: 17px;
}
.sky-settings-icon {
@@ -1,4 +1,4 @@
import { createSSRApp } from 'vue'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
@@ -26,7 +26,9 @@ describe('SkySettingsRangeRow', () => {
expect(html).toContain('min="25"')
expect(html).toContain('max="100"')
expect(html).toContain('step="5"')
expect(html).toContain('75%')
expect(html).toContain('class="sky-settings-range-row__title">Brightness')
expect(html).toContain('class="sky-settings-range-row__value">75%')
expect(html).not.toContain('sky-range__caption')
})
it('uses the effective numeric value as its default visible label', async () => {
@@ -37,7 +39,9 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
expect(html).toMatch(/class="sky-range__label">.*1\.25.*<\/span>/)
expect(html).toMatch(
/class="sky-settings-range-row__value">.*1\.25.*<\/span>/,
)
expect(html).not.toContain('aria-valuetext=')
})
@@ -51,7 +55,31 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
expect(html).toContain('aria-valuetext="75%"')
expect(html).toMatch(/class="sky-range__label">.*75%.*<\/span>/)
expect(html).toMatch(
/class="sky-settings-range-row__value">.*75%.*<\/span>/,
)
})
it('supports decorative range endpoint icons without changing the label', async () => {
const app = createSSRApp({
render: () =>
h(
SkySettingsRangeRow,
{ modelValue: 50, title: 'Volume', valueLabel: '50%' },
{
leading: () => h('svg', { 'data-endpoint': 'low' }),
trailing: () => h('svg', { 'data-endpoint': 'high' }),
},
),
})
const html = await renderToString(app)
expect(html).toContain('sky-settings-range-row__endpoint--leading')
expect(html).toContain('sky-settings-range-row__endpoint--trailing')
expect(html).toContain('data-endpoint="low"')
expect(html).toContain('data-endpoint="high"')
expect(html).toContain('aria-label="Volume"')
})
it('exposes input, change, and numeric model update events', () => {
@@ -53,21 +53,38 @@ const accessibleValue = computed(
:class="{ 'sky-settings-range-row--disabled': disabled }"
>
<div class="sky-settings-range-row__frame">
<SkyRange
:aria-label="title"
:aria-value-text="accessibleValue"
:caption="title"
:disabled="disabled"
:max="max"
:min="min"
:model-value="effectiveValue"
:step="step"
@change="emit('change', $event)"
@input="emit('input', $event)"
@update:model-value="emit('update:modelValue', $event)"
>
{{ visibleValue }}
</SkyRange>
<div class="sky-settings-range-row__header">
<span class="sky-settings-range-row__title">{{ title }}</span>
<span class="sky-settings-range-row__value">{{ visibleValue }}</span>
</div>
<div class="sky-settings-range-row__control">
<span
v-if="$slots.leading"
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--leading"
aria-hidden="true"
>
<slot name="leading" />
</span>
<SkyRange
:aria-label="title"
:aria-value-text="accessibleValue"
:disabled="disabled"
:max="max"
:min="min"
:model-value="effectiveValue"
:step="step"
@change="emit('change', $event)"
@input="emit('input', $event)"
@update:model-value="emit('update:modelValue', $event)"
/>
<span
v-if="$slots.trailing"
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--trailing"
aria-hidden="true"
>
<slot name="trailing" />
</span>
</div>
</div>
</li>
</template>