@@ -488,7 +534,11 @@ function finish(): void {
{{ phone.t('Setup.security.createSelected', { count: String(passcodeLength) }) }}{{
+ phone.t('Setup.security.createSelected', {
+ count: String(passcodeLength),
+ })
+ }}
@@ -987,7 +1043,13 @@ function finish(): void {
bottom: 44px;
left: 12px;
background:
- linear-gradient(90deg, transparent, rgb(55 122 255 / 13%) 28%, rgb(121 91 255 / 11%) 72%, transparent),
+ linear-gradient(
+ 90deg,
+ transparent,
+ rgb(55 122 255 / 13%) 28%,
+ rgb(121 91 255 / 11%) 72%,
+ transparent
+ ),
linear-gradient(180deg, transparent, rgb(39 126 255 / 9%) 48%, transparent);
filter: blur(22px);
content: '';
@@ -1023,8 +1085,12 @@ function finish(): void {
transform: translateY(12px) scale(0.94);
animation: setup-welcome-language 9s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
-.setup-welcome__greetings span:nth-child(2) { animation-delay: 3s; }
-.setup-welcome__greetings span:nth-child(3) { animation-delay: 6s; }
+.setup-welcome__greetings span:nth-child(2) {
+ animation-delay: 3s;
+}
+.setup-welcome__greetings span:nth-child(3) {
+ animation-delay: 6s;
+}
.setup-welcome__signature {
position: absolute;
bottom: 66px;
@@ -1072,7 +1138,11 @@ function finish(): void {
flex-direction: column;
align-items: center;
border-top: 1px solid rgb(255 255 255 / 7%);
- background: linear-gradient(180deg, rgb(7 10 20 / 30%), rgb(4 7 13 / 94%) 28%);
+ background: linear-gradient(
+ 180deg,
+ rgb(7 10 20 / 30%),
+ rgb(4 7 13 / 94%) 28%
+ );
backdrop-filter: blur(24px);
}
.setup-welcome__privacy {
@@ -1109,13 +1179,34 @@ function finish(): void {
background: rgb(255 255 255 / 92%);
}
@keyframes setup-welcome-language {
- 0% { opacity: 0; transform: translateY(12px) scale(0.94); filter: blur(5px); }
- 8%, 27% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
- 34%, 100% { opacity: 0; transform: translateY(-10px) scale(1.03); filter: blur(4px); }
+ 0% {
+ opacity: 0;
+ transform: translateY(12px) scale(0.94);
+ filter: blur(5px);
+ }
+ 8%,
+ 27% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: blur(0);
+ }
+ 34%,
+ 100% {
+ opacity: 0;
+ transform: translateY(-10px) scale(1.03);
+ filter: blur(4px);
+ }
}
@keyframes setup-welcome-breathe {
- 0%, 100% { transform: scaleX(0.92); opacity: 0.58; }
- 50% { transform: scaleX(1.04); opacity: 1; }
+ 0%,
+ 100% {
+ transform: scaleX(0.92);
+ opacity: 0.58;
+ }
+ 50% {
+ transform: scaleX(1.04);
+ opacity: 1;
+ }
}
.setup-connectivity-card {
position: relative;
@@ -1195,7 +1286,9 @@ function finish(): void {
font-size: 12px;
line-height: 1.35;
}
-.setup-assistant--step-2 .setup-assistant__eyebrow { margin-top: 8px; }
+.setup-assistant--step-2 .setup-assistant__eyebrow {
+ margin-top: 8px;
+}
.setup-cloud-hero {
position: relative;
display: grid;
@@ -1223,7 +1316,9 @@ function finish(): void {
content: '';
transform: rotate(58deg);
}
-.setup-cloud-hero__orbit::after { transform: rotate(-58deg); }
+.setup-cloud-hero__orbit::after {
+ transform: rotate(-58deg);
+}
.setup-cloud-hero__glow {
position: absolute;
inset: 13px;
@@ -1231,7 +1326,10 @@ function finish(): void {
background: rgb(38 125 255 / 22%);
box-shadow: 0 0 35px 10px rgb(39 115 255 / 22%);
}
-.setup-cloud-hero svg { position: relative; filter: drop-shadow(0 6px 12px rgb(25 102 255 / 42%)); }
+.setup-cloud-hero svg {
+ position: relative;
+ filter: drop-shadow(0 6px 12px rgb(25 102 255 / 42%));
+}
.setup-cloud-hero i {
position: absolute;
width: 3px;
@@ -1240,10 +1338,22 @@ function finish(): void {
background: #8ac1ff;
box-shadow: 0 0 8px #479eff;
}
-.setup-cloud-hero i:nth-of-type(1) { top: 2px; left: 23px; }
-.setup-cloud-hero i:nth-of-type(2) { right: -1px; bottom: 25px; }
-.setup-cloud-hero i:nth-of-type(3) { bottom: 3px; left: 11px; }
-.setup-cloud-form { margin-top: 11px; gap: 7px; }
+.setup-cloud-hero i:nth-of-type(1) {
+ top: 2px;
+ left: 23px;
+}
+.setup-cloud-hero i:nth-of-type(2) {
+ right: -1px;
+ bottom: 25px;
+}
+.setup-cloud-hero i:nth-of-type(3) {
+ bottom: 3px;
+ left: 11px;
+}
+.setup-cloud-form {
+ margin-top: 11px;
+ gap: 7px;
+}
.setup-cloud-identity {
display: flex;
min-height: 47px;
@@ -1253,7 +1363,11 @@ function finish(): void {
gap: 10px;
border: 1px solid rgb(113 170 255 / 12%);
border-radius: 16px;
- background: linear-gradient(135deg, rgb(49 115 213 / 14%), rgb(255 255 255 / 4%));
+ background: linear-gradient(
+ 135deg,
+ rgb(49 115 213 / 14%),
+ rgb(255 255 255 / 4%)
+ );
text-align: left;
}
.setup-cloud-identity > span {
@@ -1264,20 +1378,45 @@ function finish(): void {
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 50%;
background: linear-gradient(145deg, #398ff6, #6d5bea);
- box-shadow: inset 0 1px rgb(255 255 255 / 28%), 0 8px 18px rgb(27 91 208 / 20%);
+ box-shadow:
+ inset 0 1px rgb(255 255 255 / 28%),
+ 0 8px 18px rgb(27 91 208 / 20%);
color: white;
font-size: 15px;
font-style: normal;
font-weight: 750;
place-items: center;
}
-.setup-cloud-identity div { min-width: 0; flex: 1; }
+.setup-cloud-identity div {
+ min-width: 0;
+ flex: 1;
+}
.setup-cloud-identity small,
-.setup-cloud-identity strong { display: block; }
-.setup-cloud-identity small { color: rgb(255 255 255 / 43%); font-size: 8px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
-.setup-cloud-identity strong { margin-top: 2px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
-.setup-cloud-identity em { color: #8dbdff; font-style: normal; font-weight: 600; }
-.setup-cloud-identity > svg { color: #67d99d; }
+.setup-cloud-identity strong {
+ display: block;
+}
+.setup-cloud-identity small {
+ color: rgb(255 255 255 / 43%);
+ font-size: 8px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.setup-cloud-identity strong {
+ margin-top: 2px;
+ overflow: hidden;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.setup-cloud-identity em {
+ color: #8dbdff;
+ font-style: normal;
+ font-weight: 600;
+}
+.setup-cloud-identity > svg {
+ color: #67d99d;
+}
.setup-cloud-fields {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 9%);
@@ -1304,17 +1443,47 @@ function finish(): void {
opacity: 1;
transform: translateY(0);
}
-.setup-cloud-fields :deep(.sky-field) { border: 0; border-radius: 0; background: transparent; }
-.setup-cloud-fields :deep(.sky-field + .sky-field) { border-top: 1px solid rgb(255 255 255 / 8%); }
-.setup-cloud-fields :deep(.sky-field) { padding: 3px 12px; }
+.setup-cloud-fields :deep(.sky-field) {
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.setup-cloud-fields :deep(.sky-field__inner) {
+ padding: 0;
+}
+.setup-cloud-fields :deep(.sky-field + .sky-field) {
+ border-top: 1px solid rgb(255 255 255 / 8%);
+}
+.setup-cloud-fields :deep(.sky-field) {
+ padding: 3px 12px;
+}
.setup-cloud-fields :deep(.sky-field__control),
-.setup-cloud-fields :deep(.sky-field__input) { min-height: 43px; }
-.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__control) { min-height: 52px; }
-.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__label) { top: 6px; left: 12px; }
-.setup-cloud-fields :deep(.sky-field__label) { color: rgb(255 255 255 / 52%); }
-.setup-cloud-fields :deep(.sky-field__input) { color: #ffffff; caret-color: #5ba9ff; }
-.setup-cloud-fields :deep(.sky-field__input::placeholder) { color: rgb(255 255 255 / 24%); }
-.setup-cloud-suffix { color: #82b8ff; font-size: 11px; font-weight: 650; white-space: nowrap; }
+.setup-cloud-fields :deep(.sky-field__input) {
+ min-height: 43px;
+}
+.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__control) {
+ min-height: 52px;
+}
+.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__label) {
+ top: 6px;
+ left: 12px;
+}
+.setup-cloud-fields :deep(.sky-field__label) {
+ color: rgb(255 255 255 / 52%);
+}
+.setup-cloud-fields :deep(.sky-field__input) {
+ color: #ffffff;
+ caret-color: #5ba9ff;
+}
+.setup-cloud-fields :deep(.sky-field__input::placeholder) {
+ color: rgb(255 255 255 / 24%);
+}
+.setup-cloud-suffix {
+ color: #82b8ff;
+ font-size: 11px;
+ font-weight: 650;
+ white-space: nowrap;
+}
.setup-cloud-strength {
display: flex;
align-items: center;
@@ -1322,9 +1491,21 @@ function finish(): void {
color: rgb(255 255 255 / 42%);
font-size: 9px;
}
-.setup-cloud-strength > div { display: flex; flex: 1; gap: 4px; }
-.setup-cloud-strength span { height: 3px; flex: 1; border-radius: 99px; background: rgb(255 255 255 / 10%); }
-.setup-cloud-strength span.active { background: linear-gradient(90deg, #318bff, #66c0ff); box-shadow: 0 0 7px rgb(49 139 255 / 34%); }
+.setup-cloud-strength > div {
+ display: flex;
+ flex: 1;
+ gap: 4px;
+}
+.setup-cloud-strength span {
+ height: 3px;
+ flex: 1;
+ border-radius: 99px;
+ background: rgb(255 255 255 / 10%);
+}
+.setup-cloud-strength span.active {
+ background: linear-gradient(90deg, #318bff, #66c0ff);
+ box-shadow: 0 0 7px rgb(49 139 255 / 34%);
+}
.setup-account-strength-enter-active,
.setup-account-strength-leave-active {
transition:
@@ -1344,9 +1525,17 @@ function finish(): void {
color: rgb(199 215 238 / 46%);
font-size: 9px;
}
-.setup-assistant--step-2 .setup-assistant__primary { min-height: 46px; }
-.setup-assistant--step-2 .setup-assistant__later { padding: 7px; }
-@keyframes setup-cloud-orbit { to { transform: rotate(360deg); } }
+.setup-assistant--step-2 .setup-assistant__primary {
+ min-height: 46px;
+}
+.setup-assistant--step-2 .setup-assistant__later {
+ padding: 7px;
+}
+@keyframes setup-cloud-orbit {
+ to {
+ transform: rotate(360deg);
+ }
+}
.setup-assistant__selector {
position: relative;
display: grid;
@@ -1474,25 +1663,47 @@ function finish(): void {
color: #ffffff;
background: rgb(255 255 255 / 5%);
text-align: left;
- transition: border-color 160ms ease, background 160ms ease, transform 120ms ease;
+ transition:
+ border-color 160ms ease,
+ background 160ms ease,
+ transform 120ms ease;
+}
+.setup-passcode-length button:active {
+ transform: scale(0.975);
}
-.setup-passcode-length button:active { transform: scale(0.975); }
.setup-passcode-length button.selected {
border-color: rgb(94 172 255 / 72%);
- background: linear-gradient(145deg, rgb(42 132 245 / 23%), rgb(64 96 210 / 10%));
- box-shadow: inset 0 1px rgb(255 255 255 / 9%), 0 9px 22px rgb(23 91 193 / 12%);
+ background: linear-gradient(
+ 145deg,
+ rgb(42 132 245 / 23%),
+ rgb(64 96 210 / 10%)
+ );
+ box-shadow:
+ inset 0 1px rgb(255 255 255 / 9%),
+ 0 9px 22px rgb(23 91 193 / 12%);
}
.setup-passcode-length button > span,
.setup-passcode-length b,
-.setup-passcode-length small { display: block; }
-.setup-passcode-length b { font-size: 18px; line-height: 1; }
-.setup-passcode-length small { margin-top: 4px; color: rgb(255 255 255 / 48%); font-size: 9px; }
+.setup-passcode-length small {
+ display: block;
+}
+.setup-passcode-length b {
+ font-size: 18px;
+ line-height: 1;
+}
+.setup-passcode-length small {
+ margin-top: 4px;
+ color: rgb(255 255 255 / 48%);
+ font-size: 9px;
+}
.setup-passcode-length svg {
padding: 3px;
border-radius: 50%;
background: #318cf5;
}
-.setup-assistant--step-3 .setup-assistant__notice { margin: 12px 0; }
+.setup-assistant--step-3 .setup-assistant__notice {
+ margin: 12px 0;
+}
.setup-choice-grid {
display: grid;
width: 100%;
@@ -1857,4 +2068,54 @@ function finish(): void {
opacity: 0;
transform: translateX(16px);
}
+@media (prefers-reduced-motion: reduce) {
+ .setup-assistant,
+ .setup-assistant *,
+ .setup-assistant *::before,
+ .setup-assistant *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+
+ .setup-assistant__aurora,
+ .setup-welcome__hero::before,
+ .setup-cloud-hero__orbit {
+ animation: none;
+ }
+
+ .setup-welcome__greetings span {
+ opacity: 0;
+ animation: none;
+ transform: none;
+ }
+
+ .setup-welcome__greetings span:first-child {
+ opacity: 1;
+ }
+
+ .setup-forward-enter-active,
+ .setup-forward-leave-active,
+ .setup-back-enter-active,
+ .setup-back-leave-active {
+ transition-duration: 0.01ms;
+ }
+
+ .setup-forward-enter-from,
+ .setup-forward-leave-to,
+ .setup-back-enter-from,
+ .setup-back-leave-to,
+ .setup-account-field-enter-from,
+ .setup-account-field-leave-to,
+ .setup-account-strength-enter-from,
+ .setup-account-strength-leave-to,
+ .setup-account-copy-enter-from,
+ .setup-account-copy-leave-to,
+ .setup-account-action-enter-from,
+ .setup-account-action-leave-to,
+ .setup-passcode-length button:active {
+ transform: none;
+ }
+}
diff --git a/frontend/src/components/PhoneStatusBarTheme.contract.test.ts b/frontend/src/components/PhoneStatusBarTheme.contract.test.ts
new file mode 100644
index 0000000..c6852f8
--- /dev/null
+++ b/frontend/src/components/PhoneStatusBarTheme.contract.test.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from 'node:fs'
+
+import { describe, expect, it } from 'vitest'
+
+const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
+const kitchenSinkSource = readFileSync(
+ new URL('../views/development/SkyUiKitchenSinkView.vue', import.meta.url),
+ 'utf8',
+)
+const indicatorsSource = readFileSync(
+ new URL('./PhoneStatusIndicators.vue', import.meta.url),
+ 'utf8',
+)
+const styles = readFileSync(
+ new URL('../assets/main.css', import.meta.url),
+ 'utf8',
+)
+
+describe('phone status bar theme contract', () => {
+ it('treats the Sky UI development catalog as app content', () => {
+ expect(appSource).toContain(
+ "'phone-screen--app': isAppRoute || isDevelopmentRoute",
+ )
+ })
+
+ it('keeps the Kitchen Sink toggle and phone shell on one theme source', () => {
+ expect(kitchenSinkSource).toMatch(
+ /const dark = computed\(\{[\s\S]*?get: \(\) => phone\.isDarkMode,[\s\S]*?phone\.setPreference\('appearanceMode', value \? 'dark' : 'light'\)/,
+ )
+ })
+
+ it('uses black in light app content and white in dark content', () => {
+ expect(styles).toMatch(/\.phone-status-bar\s*\{[^}]*color:\s*#fff;/s)
+ expect(styles).toMatch(
+ /\.phone-screen--app \.phone-app--light \.phone-status-bar\s*\{\s*color:\s*#000;/,
+ )
+ })
+
+ it('keeps every status indicator bound to the inherited foreground', () => {
+ expect(indicatorsSource).toMatch(/
{
transform: scale(0.86);
}
}
+
+@media (prefers-reduced-motion: reduce) {
+ .radio-hud__icon {
+ animation: none;
+ }
+
+ .radio-hud-member-enter-active,
+ .radio-hud-member-leave-active {
+ transition: none;
+ }
+
+ .radio-hud-member-enter-from,
+ .radio-hud-member-leave-to,
+ .radio-hud[data-horizontal='left'] .radio-hud-member-enter-from,
+ .radio-hud[data-horizontal='left'] .radio-hud-member-leave-to {
+ opacity: 1;
+ transform: none;
+ }
+}
diff --git a/frontend/src/components/SpringboardWidget.vue b/frontend/src/components/SpringboardWidget.vue
index 3428988..9ba916b 100644
--- a/frontend/src/components/SpringboardWidget.vue
+++ b/frontend/src/components/SpringboardWidget.vue
@@ -341,6 +341,7 @@ onBeforeUnmount(() => {
:class="[
`home-widget--${instance.kind}`,
{
+ 'phone-effect--solid-surface': instance.kind !== 'weather',
'home-widget--music-empty':
instance.kind === 'music' && !music.current.value,
},
@@ -641,6 +642,7 @@ onBeforeUnmount(() => {
}
.home-widget {
+ --phone-effect-solid-background: #19191b;
position: relative;
box-sizing: border-box;
width: 100%;
@@ -1403,10 +1405,6 @@ onBeforeUnmount(() => {
line-height: 1;
}
-:global(.phone-app--performance) .home-widget:not(.home-widget--weather) {
- background-color: #19191b;
-}
-
@keyframes widget-wobble {
from {
transform: rotate(-0.7deg) translateY(-0.5px);
diff --git a/frontend/src/components/account/AppProfileAuth.vue b/frontend/src/components/account/AppProfileAuth.vue
index 7c8df13..e675c2d 100644
--- a/frontend/src/components/account/AppProfileAuth.vue
+++ b/frontend/src/components/account/AppProfileAuth.vue
@@ -186,11 +186,17 @@ const canSubmit = computed(() => {
height: 46px;
grid-row: 1 / span 2;
place-items: center;
- border: 1px solid
- color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
+ border: 1px solid rgba(255, 214, 62, 0.46);
+ border-color: color-mix(
+ in srgb,
+ var(--auth-accent, #ffd63e) 46%,
+ transparent
+ );
border-radius: 15px;
color: var(--auth-accent, var(--yellow, #ffd63e));
+ background: rgba(255, 214, 62, 0.14);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
+ box-shadow: 0 10px 28px rgba(255, 214, 62, 0.16);
box-shadow: 0 10px 28px
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
}
@@ -221,6 +227,7 @@ const canSubmit = computed(() => {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
+ background: var(--panel, #20262c);
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(22px) saturate(1.15);
@@ -232,6 +239,7 @@ const canSubmit = computed(() => {
width: 170px;
height: 150px;
border-radius: 50%;
+ background: rgba(255, 214, 62, 0.16);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
filter: blur(38px);
content: '';
@@ -279,10 +287,15 @@ const canSubmit = computed(() => {
height: 66px;
flex: none;
place-items: center;
- border: 2px solid
- color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
+ border: 2px solid rgba(255, 214, 62, 0.58);
+ border-color: color-mix(
+ in srgb,
+ var(--auth-accent, #ffd63e) 58%,
+ transparent
+ );
border-radius: 50%;
color: var(--auth-accent, var(--yellow, #ffd63e));
+ background: var(--panel, #20262c);
background: color-mix(
in srgb,
var(--auth-accent, #ffd63e) 10%,
@@ -344,6 +357,7 @@ const canSubmit = computed(() => {
place-items: center;
border-radius: 11px;
color: var(--auth-accent, #ffd63e);
+ background: rgba(255, 214, 62, 0.13);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
}
.app-profile-auth__identity div {
@@ -379,6 +393,7 @@ const canSubmit = computed(() => {
color: inherit !important;
}
.app-profile-auth__fields :deep(.text-xs > div) {
+ background: var(--panel, #20262c) !important;
background: color-mix(
in srgb,
var(--panel, #20262c) 94%,
@@ -404,6 +419,7 @@ const canSubmit = computed(() => {
padding: 0 17px;
color: #fff !important;
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
+ box-shadow: 0 10px 26px rgba(255, 214, 62, 0.25);
box-shadow: 0 10px 26px
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
font-weight: 750;
diff --git a/frontend/src/ui/SkyAppPage.vue b/frontend/src/ui/SkyAppPage.vue
index 5255531..653746d 100644
--- a/frontend/src/ui/SkyAppPage.vue
+++ b/frontend/src/ui/SkyAppPage.vue
@@ -10,12 +10,14 @@ const props = withDefaults(
defineProps<{
accent?: string
accentSoft?: string
+ component?: string
dark?: boolean
label: string
}>(),
{
accent: '',
accentSoft: '',
+ component: 'main',
dark: undefined,
},
)
@@ -52,7 +54,8 @@ const pageStyle = computed(
- (
>
-
+
diff --git a/frontend/src/ui/SkyNavbar.test.ts b/frontend/src/ui/SkyNavbar.test.ts
index 5d37720..fbac00b 100644
--- a/frontend/src/ui/SkyNavbar.test.ts
+++ b/frontend/src/ui/SkyNavbar.test.ts
@@ -14,6 +14,10 @@ const foundationStyles = readFileSync(
new URL('./foundation.css', import.meta.url),
'utf8',
)
+const controlsStyles = readFileSync(
+ new URL('./controls.css', import.meta.url),
+ 'utf8',
+)
const appPageSource = readFileSync(
new URL('./SkyAppPage.vue', import.meta.url),
'utf8',
@@ -84,7 +88,7 @@ describe('SkyNavbar', () => {
)?.groups?.declarations
expect(noNavigationRule).toBeDefined()
- expect(noNavigationRule).toContain('var(--sky-safe-area-top)')
+ expect(noNavigationRule).toContain('var(--sky-navbar-safe-area-top)')
expect(noNavigationRule).toContain('var(--sky-navbar-height)')
expect(noNavigationRule).toContain('var(--sky-space-3)')
expect(navigationRowRule).toBeDefined()
@@ -211,6 +215,40 @@ describe('SkyNavbar', () => {
expect(html).toContain('sky-navbar__back--surface')
expect(html).toContain('aria-label="Back to Settings"')
+ expect(html).toContain('class="sky-navbar__back-icon"')
+ expect(html).toContain('viewBox="0 0 12 20"')
+ expect(html).not.toContain('lucide-chevron-left')
+ })
+
+ it('keeps the Konsta glass wrappers and centers the exact iOS back icon', () => {
+ expect(navbarSource).toContain('class="sky-navbar__left sky-glass-surface"')
+ expect(navbarSource).toContain(
+ 'class="sky-navbar__right sky-glass-surface"',
+ )
+ expect(foundationStyles).toMatch(
+ /\.sky-navbar__back\s*\{[^}]*padding:\s*0 16px;[^}]*justify-content:\s*center/s,
+ )
+ expect(foundationStyles).toMatch(
+ /\.sky-navbar__back-icon\s*\{[^}]*width:\s*12px;[^}]*height:\s*20px;[^}]*display:\s*block;[^}]*fill:\s*currentColor/s,
+ )
+ })
+
+ it('inherits the Konsta iOS foreground for navbar and toolbar actions', () => {
+ expect(foundationStyles).toMatch(
+ /\.sky-navbar__back\s*\{[^}]*color:\s*inherit;/s,
+ )
+ expect(controlsStyles).toMatch(
+ /\.sky-navbar \.sky-link,\s*\.sky-navbar \.sky-navbar-back-link,\s*\.sky-toolbar \.sky-link\s*\{[^}]*color:\s*inherit;/s,
+ )
+ expect(controlsStyles).toMatch(
+ /\.sky-link\s*\{[^}]*color:\s*var\(--sky-app-accent, #007aff\);/s,
+ )
+ expect(controlsStyles).toMatch(
+ /\.sky-navbar-back-link:active:not\(:disabled\)\s*\{[^}]*opacity:\s*0\.5;[^}]*transition-duration:\s*0ms;/s,
+ )
+ expect(controlsStyles).not.toMatch(
+ /\.sky-navbar-back-link:active:not\(:disabled\)\s*\{[^}]*--sky-app-accent-soft/s,
+ )
})
it('renders the Konsta 56px subnavbar and custom class hook', async () => {
@@ -238,6 +276,9 @@ describe('SkyNavbar', () => {
})
it('uses the Konsta navbar glass heights with and without subnavbar', () => {
+ const navbarRule = foundationStyles.match(
+ /(?:^|\n)\.sky-navbar\s*\{(?[^}]*)\}/,
+ )?.groups?.declarations
const baseEffectRule = foundationStyles.match(
/\.sky-navbar__blur,\s*\.sky-navbar__background\s*\{(?[^}]*)\}/,
)?.groups?.declarations
@@ -245,10 +286,13 @@ describe('SkyNavbar', () => {
/\.sky-navbar--with-subnavbar \.sky-navbar__blur,\s*\.sky-navbar--with-subnavbar \.sky-navbar__background\s*\{(?[^}]*)\}/,
)?.groups?.declarations
- expect(baseEffectRule).toContain('var(--sky-safe-area-top)')
+ expect(navbarRule).toContain(
+ '--sky-navbar-safe-area-top: max(16px, var(--sky-safe-area-top))',
+ )
+ expect(baseEffectRule).toContain('var(--sky-navbar-safe-area-top)')
expect(baseEffectRule).toContain('var(--sky-navbar-height)')
expect(baseEffectRule).toContain('+ 16px')
- expect(subnavbarEffectRule).toContain('var(--sky-safe-area-top)')
+ expect(subnavbarEffectRule).toContain('var(--sky-navbar-safe-area-top)')
expect(subnavbarEffectRule).toContain('var(--sky-navbar-height)')
expect(subnavbarEffectRule).toContain('+ 70px + 16px')
expect(foundationStyles).toMatch(
@@ -270,7 +314,12 @@ describe('SkyNavbar', () => {
subnavbar: () =>
h(
SkySegmented,
- { activeIndex: 0, ariaLabel: 'Store sections', itemCount: 2 },
+ {
+ activeIndex: 0,
+ ariaLabel: 'Store sections',
+ itemCount: 2,
+ strong: true,
+ },
{
default: () => [
h(SkySegmentedButton, { active: true }, () => 'Apps'),
diff --git a/frontend/src/ui/SkyNavbar.vue b/frontend/src/ui/SkyNavbar.vue
index 7d42765..d9ff3aa 100644
--- a/frontend/src/ui/SkyNavbar.vue
+++ b/frontend/src/ui/SkyNavbar.vue
@@ -1,5 +1,4 @@
diff --git a/frontend/src/ui/controls/SkyButton.test.ts b/frontend/src/ui/controls/SkyButton.test.ts
index a014360..276bb59 100644
--- a/frontend/src/ui/controls/SkyButton.test.ts
+++ b/frontend/src/ui/controls/SkyButton.test.ts
@@ -48,7 +48,7 @@ describe('SkyButton', () => {
expect(buttonStyles).not.toContain('--sky-app-accent-shade')
})
- it('keeps outline hover border, surface, and text in one color state', () => {
+ it('keeps outline text accented through hover and pressed states', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const buttonStyles = controls.slice(
@@ -56,11 +56,12 @@ describe('SkyButton', () => {
controls.indexOf('.sky-badge'),
)
+ expect(buttonStyles).not.toContain('.sky-button--outline:hover')
expect(buttonStyles).toMatch(
- /@media \(hover: hover\) and \(pointer: fine\)[\s\S]*?\.sky-button--outline:hover:not\(:disabled\)\s*\{[\s\S]*?border-color:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?background:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?color:\s*#ffffff;/,
+ /\.sky-button--outline:active:not\(:disabled\)\s*\{[\s\S]*?background:\s*var\(--sky-app-accent-soft, rgba\(0, 122, 255, 0\.15\)\);[\s\S]*?color:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?filter:\s*none;/,
)
expect(buttonStyles).toMatch(
- /\.sky-button--danger\.sky-button--outline:hover:not\(:disabled\)\s*\{[\s\S]*?border-color:\s*var\(--sky-danger, #dc2626\);[\s\S]*?background:\s*var\(--sky-danger, #dc2626\);[\s\S]*?color:\s*#ffffff;/,
+ /\.sky-button--danger\.sky-button--outline:active:not\(:disabled\)\s*\{[\s\S]*?background:\s*var\(--sky-danger-soft, rgba\(220, 38, 38, 0\.14\)\);[\s\S]*?color:\s*var\(--sky-danger, #dc2626\);/,
)
expect(buttonStyles).toContain(
'color var(--sky-transition-fast, 100ms) ease,',
diff --git a/frontend/src/ui/controls/SkyField.test.ts b/frontend/src/ui/controls/SkyField.test.ts
index a669cc1..1c534ff 100644
--- a/frontend/src/ui/controls/SkyField.test.ts
+++ b/frontend/src/ui/controls/SkyField.test.ts
@@ -39,7 +39,7 @@ describe('SkyField', () => {
expect(html).toContain('value="231"')
})
- it('marks fields with leading media so labels and controls share alignment', async () => {
+ it('keeps the leading alias on the canonical Konsta media column', async () => {
const app = createSSRApp({
render: () =>
h(
@@ -52,7 +52,28 @@ describe('SkyField', () => {
const html = await renderToString(app)
expect(html).toContain('sky-field--has-leading')
- expect(html).toContain('sky-field__leading')
+ expect(html).toContain('sky-field--has-media')
+ expect(html).toContain('class="sky-field__media"')
+ expect(html.indexOf('sky-field__media')).toBeLessThan(
+ html.indexOf('sky-field__inner'),
+ )
+ })
+
+ it('renders canonical media, inner, label, and control nesting', async () => {
+ const app = createSSRApp({
+ render: () =>
+ h(
+ SkyField,
+ { label: 'Name' },
+ { media: () => h('img', { alt: 'icon', src: '/demo.png' }) },
+ ),
+ })
+
+ const html = await renderToString(app)
+
+ expect(html).toMatch(
+ /[\s\S]*?<\/span>
-