diff --git a/assets/css/os-settings.css b/assets/css/os-settings.css index cb369e7e..c21b22d7 100644 --- a/assets/css/os-settings.css +++ b/assets/css/os-settings.css @@ -1994,202 +1994,705 @@ /* ------------------------------------------------------------------ * OS Settings → Themes. * - * Card grid of the site's desktop-theme library. Deliberately styled - * with the SAME neutral palette as the rest of the panel rather than - * with themed tokens: the picker has to stay legible while the user - * is trying on themes, including ones with hostile contrast. + * A preview-first wardrobe: current look on the stage, personal + * choices in the gallery, site-wide package work in a disclosure. * ------------------------------------------------------------------ */ -.os-settings__theme-grid { - display: grid; - grid-template-columns: repeat( auto-fill, minmax( 160px, 1fr ) ); - gap: 12px; - margin: 12px 0 16px; +.os-settings__themes { + container: os-themes / inline-size; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-settings__themes-header { + max-width: 680px; + margin: 0 0 18px; +} + +.os-settings__theme-eyebrow { + display: block; + margin: 0 0 3px; + color: var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + line-height: 1.4; + text-transform: uppercase; +} + +.os-settings__themes-title { + margin: 0; + font-size: clamp( 22px, 5cqi, 34px ); + font-weight: 650; + letter-spacing: -0.035em; + line-height: 1.08; +} + +.os-settings__themes-intro { + max-width: 610px; + margin: 9px 0 0; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 13px; } -.os-settings__theme-card-wrap { +/* The active theme is the only large showcase surface in the tab. */ +.os-settings__theme-stage { position: relative; + display: grid; + grid-template-columns: minmax( 0, 1.45fr ) minmax( 220px, 0.75fr ); + min-height: 260px; + overflow: hidden; + background: var( --os-ui-surface-elevated, #f6f7f7 ); + border: 1px solid var( --os-ui-border-strong, #a7aaad ); + border-radius: 16px; + box-shadow: var( --os-ui-card-shadow-hover, 0 12px 30px rgba( 0, 0, 0, 0.12 ) ); } -/* - * Same geometry in every state: the border is 1px in both, and - * selection is a box-shadow ring OUTSIDE the box, so an active card - * occupies exactly the pixels an idle one does and the row stays - * aligned. The selected ring is the flat accent, the same language - * as the wallpaper tiles and layout cards beside it. - */ -.os-settings__theme-card { +.os-settings__theme-stage::after { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + content: ""; + pointer-events: none; +} + +.os-settings__theme-stage-preview { + min-width: 0; + min-height: 260px; + background: var( --os-ui-surface-sunken, #e9e9eb ); + border-inline-end: 1px solid var( --os-ui-border, #dcdcde ); +} + +.os-settings__theme-stage-copy { display: flex; flex-direction: column; - gap: 6px; - width: 100%; - height: 100%; - padding: 10px; - background: var( --os-ui-surface, #fff ); - border: 1px solid var( --os-ui-border, #dcdcde ); - border-radius: 10px; - cursor: pointer; - text-align: start; - font: inherit; - color: var( --os-ui-fg, #1d2327 ); - transition: border-color 0.15s ease, box-shadow 0.15s ease; + align-items: flex-start; + justify-content: center; + min-width: 0; + padding: 28px; } -.os-settings__theme-card:hover { - border-color: var( --os-ui-border-strong, #8c8f94 ); +.os-settings__theme-stage-name { + margin: 0; + font-size: 24px; + font-weight: 650; + letter-spacing: -0.025em; + line-height: 1.15; + overflow-wrap: anywhere; } -.os-settings__theme-card:focus-visible { - outline: 2px solid var( --os-ui-accent, #2271b1 ); - outline-offset: 2px; +.os-settings__theme-stage-description { + margin: 10px 0 0; + color: var( --os-ui-fg-muted, #50575e ); + line-height: 1.55; } -.os-settings__theme-card[ aria-pressed="true" ] { - border-color: transparent; - box-shadow: 0 0 0 2px var( --os-ui-accent, #2271b1 ); +.os-settings__theme-facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 14px; +} + +.os-settings__theme-facts span, +.os-settings__theme-count { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + color: var( --os-ui-fg-muted, #50575e ); + background: var( --os-ui-surface-sunken, rgba( 0, 0, 0, 0.05 ) ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 999px; + font-size: 10px; + font-weight: 600; } -.os-settings__theme-preview { +.os-settings__theme-stage-actions { + display: flex; + margin-top: 20px; +} + +/* One artwork primitive serves the hero, gallery, and package list. */ +.os-settings__theme-artwork { + position: relative; display: flex; align-items: center; justify-content: center; - aspect-ratio: 16 / 10; + width: 100%; + height: 100%; overflow: hidden; + background: + linear-gradient( + 135deg, + color-mix( + in srgb, + var( --os-ui-accent, #2271b1 ) 9%, + transparent + ), + transparent 52% + ), + var( --os-ui-surface-sunken, #e9e9eb ); +} + +.os-settings__theme-artwork--card { + aspect-ratio: 16 / 10; +} + +.os-settings__theme-artwork--manage { + width: 72px; + height: 45px; + flex: 0 0 72px; + border: 1px solid var( --os-ui-border, #dcdcde ); border-radius: 6px; - background: var( --os-ui-surface-elevated, #f0f0f1 ); } -.os-settings__theme-preview img { +.os-settings__theme-artwork img { + display: block; width: 100%; height: 100%; object-fit: cover; +} + +.os-settings__theme-initials { + font-size: 28px; + font-weight: 750; + letter-spacing: 0.04em; + color: var( --os-ui-fg-muted, #646970 ); +} + +.os-settings__theme-artwork--manage .os-settings__theme-initials { + font-size: 13px; +} + +/* + * Built-in system preview: desktop field, focused window, and dock. + * + * The ONE place in this file that spells its colours out instead of + * reading tokens, and deliberately so. This miniature depicts a fixed + * thing — the look OpenStation ships wearing — and it has to keep + * depicting it while the user is wearing something else. Read through + * tokens, the "OpenStation" card would repaint itself in Legacy's + * greys the moment Legacy was picked, and the card would be lying + * about the very option it is offering. + * + * So these are literals, and every one of them is a real value copied + * from `variables.css` rather than a hex chosen to look nice: Void + * `#0c0b0f` for the field and the title bar (`--os-titlebar-bg`), + * Obsidian `#1a1721` for the window body (`--os-window-bg`), Silver + * `#4d4a52` for its content lines, Starlight `#fffbff` for the dock + * icons, and Pulse `#f252fc` with Nebula `#ec9bff` as the identity + * moments. Retinting the shell means updating them here to match. + */ +.os-settings__theme-system-scene { + position: absolute; + inset: 0; display: block; + background: + radial-gradient( circle at 72% 20%, rgba( 236, 155, 255, 0.34 ), transparent 28% ), + radial-gradient( circle at 18% 82%, rgba( 242, 82, 252, 0.2 ), transparent 30% ), + linear-gradient( 145deg, #0c0b0f 0%, #33303a 52%, #0c0b0f 100% ); } -/* Stand-in preview for "System default" — a miniature of the shipped - * graphite desktop, so the card reads as a real option rather than an - * absence. */ -.os-settings__theme-preview--system { - background: linear-gradient( 135deg, #1d2327 0%, #2c3338 50%, #1d2327 100% ); +.os-settings__theme-system-window { + position: absolute; + inset: 14% 15% 24% 12%; + display: flex; + flex-direction: column; + overflow: hidden; + background: #1a1721; + border: 1px solid rgba( 255, 251, 255, 0.16 ); + border-radius: 5px; + box-shadow: 0 12px 24px rgba( 0, 0, 0, 0.4 ); } -.os-settings__theme-initials { - font-size: 24px; - font-weight: 700; - letter-spacing: 1px; - color: var( --os-ui-fg-muted, #787c82 ); +.os-settings__theme-system-titlebar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 3%; + height: 21%; + min-height: 8px; + padding-inline: 4%; + background: #0c0b0f; +} + +/* Window controls: the focused one carries Pulse, as the shell's do. */ +.os-settings__theme-system-titlebar span { + width: 5%; + aspect-ratio: 1; + background: #4d4a52; + border-radius: 50%; } -.os-settings__theme-name { - font-weight: 600; - line-height: 1.3; - overflow-wrap: anywhere; +.os-settings__theme-system-titlebar span:last-child { + background: #f252fc; } -/* Wraps rather than clipping. This string is a theme's own - description and a plugin can make it any length; a one-line box cut - "The look OpenStation ships with" off mid-sentence. */ -.os-settings__theme-meta { - font-size: 13px; - color: var( --os-ui-fg-muted, #646970 ); - line-height: 1.45; - min-height: 1.45em; - overflow-wrap: anywhere; +.os-settings__theme-system-content { + display: flex; + flex: 1; + flex-direction: column; + gap: 9%; + padding: 9%; } -/* Delete overlay — outside the card button, because nesting a button - * inside a button is invalid HTML and breaks keyboard traversal. */ -.os-settings__theme-delete { +.os-settings__theme-system-content span { + height: 7%; + background: #4d4a52; + border-radius: 999px; +} + +.os-settings__theme-system-content span:nth-child( 2 ) { + width: 72%; +} + +.os-settings__theme-system-content span:nth-child( 3 ) { + width: 48%; +} + +.os-settings__theme-system-dock { position: absolute; - inset-block-start: 4px; - inset-inline-end: 4px; + inset: auto 25% 7% 25%; display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + gap: 7%; + height: 12%; + min-height: 8px; + background: rgba( 12, 11, 15, 0.72 ); + border: 1px solid rgba( 255, 251, 255, 0.16 ); + border-radius: 999px; + box-shadow: 0 5px 14px rgba( 0, 0, 0, 0.35 ); +} + +.os-settings__theme-system-dock span { + width: 8%; + aspect-ratio: 1; + background: #fffbff; + border-radius: 3px; +} + +.os-settings__theme-system-dock span:nth-child( 2 ), +.os-settings__theme-system-dock span:nth-child( 4 ) { + background: #f252fc; +} + +/* + * Gallery uses native radios for the group semantics, but activation + * is manual — see the arrow-key handler in `sections/themes.ts` for + * why selecting a theme is too consequential to follow focus. + */ +.os-settings__theme-library { + min-width: 0; + margin: 30px 0 0; padding: 0; - border: none; - border-radius: 50%; - background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) ); - color: var( --os-ui-fg-on-accent, #fff ); - font-size: 15px; - line-height: 1; + border: 0; +} + +.os-settings__theme-library-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + width: 100%; + margin: 0 0 12px; + padding: 0; +} + +.os-settings__theme-library-heading > span:first-child { + display: block; +} + +.os-settings__theme-library-heading strong { + display: block; + font-size: 17px; + font-weight: 650; + line-height: 1.25; +} + +.os-settings__theme-grid { + display: grid; + grid-template-columns: repeat( auto-fill, minmax( 220px, 1fr ) ); + gap: 16px; +} + +.os-settings__theme-choice { + position: relative; + display: block; + min-width: 0; cursor: pointer; +} + +.os-settings__theme-choice-input { + position: absolute; + inset: 0 auto auto 0; + width: 1px; + height: 1px; + margin: 0; opacity: 0; - transition: opacity 0.15s ease, background-color 0.15s ease; } -.os-settings__theme-card-wrap:hover - .os-settings__theme-delete, -.os-settings__theme-delete:focus-visible { - opacity: 1; +.os-settings__theme-choice-card { + display: flex; + height: 100%; + overflow: hidden; + flex-direction: column; + background: var( --os-ui-surface, #fff ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 12px; + box-shadow: var( --os-ui-card-shadow, 0 3px 10px rgba( 0, 0, 0, 0.07 ) ); + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; } -.os-settings__theme-delete:hover { - background: var( --os-ui-danger, #d63638 ); +.os-settings__theme-choice:hover .os-settings__theme-choice-card { + border-color: var( --os-ui-border-strong, #8c8f94 ); + box-shadow: var( --os-ui-card-shadow-hover, 0 10px 24px rgba( 0, 0, 0, 0.13 ) ); + transform: translateY( -2px ); +} + +.os-settings__theme-choice-input:checked + + .os-settings__theme-choice-card { + border-color: var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + box-shadow: + 0 0 0 2px var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ), + var( --os-ui-card-shadow-hover, 0 10px 24px rgba( 0, 0, 0, 0.13 ) ); } /* - * "Apply 's recommended layout" — sits between the theme grid - * and the upload tile, and only for a theme that recommends - * something. Just the button: the dock resizing and the layout - * moving is the feedback. + * After `:checked` on purpose. The two selectors weigh the same, so + * source order is the only thing deciding which ring a focused, + * selected card wears — and it has to be this one. The accent border + * above still says "selected" underneath it. */ -.os-settings__theme-recommendation { +.os-settings__theme-choice-input:focus-visible + + .os-settings__theme-choice-card { + /* Above the neighbouring cards: they are opaque, and the ring + reaches past the grid gap into them. */ + position: relative; + z-index: 1; + box-shadow: var( --os-ui-focus-ring, 0 0 0 2px #2271b1 ); +} + +/* Letterbox behind preview art of any aspect ratio. */ +.os-settings__theme-choice-preview { + position: relative; + display: block; + background: var( --os-ui-surface-sunken, #e9e9eb ); + border-bottom: 1px solid var( --os-ui-border, #dcdcde ); +} + +.os-settings__theme-current-mark { + position: absolute; + inset: 10px 10px auto auto; + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + color: var( --os-ui-fg-on-accent, #fff ); + background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) ); + border: 1px solid rgba( 255, 255, 255, 0.32 ); + border-radius: 999px; + box-shadow: var( --os-ui-card-mark-shadow, 0 3px 10px rgba( 0, 0, 0, 0.3 ) ); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + backdrop-filter: blur( 8px ); +} + +.os-settings__theme-choice-copy { display: flex; - margin-block: 16px; + flex: 1; + flex-direction: column; + align-items: flex-start; + min-width: 0; + padding: 12px 13px 13px; } -/* - * The last cell of the theme grid, not a dropzone underneath it. - * Uploading a theme is one of the ways to answer "which theme", so it - * belongs in the row of answers; dashed because it is the only one - * that cannot show you what you are picking until you have picked it. - * Same reasoning, and the same treatment, as "Use your own image" in - * the wallpaper grid. - */ -.os-settings__theme-upload { +.os-settings__theme-choice-name { + font-size: 14px; + font-weight: 650; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.os-settings__theme-choice-description { + display: -webkit-box; + overflow: hidden; + margin-top: 5px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 11px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.os-settings__theme-choice-byline { + margin-top: auto; + padding-top: 10px; + color: var( --os-ui-fg-faint, #787c82 ); + font-size: 10px; + font-weight: 600; +} + +/* Administration is deliberately quieter than choosing a look. */ +.os-settings__theme-management { + margin-top: 30px; + overflow: hidden; + background: var( --os-ui-surface-elevated, #f6f7f7 ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 12px; +} + +.os-settings__theme-management-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 64px; + padding: 10px 48px 10px 16px; + cursor: pointer; + list-style: none; + position: relative; +} + +.os-settings__theme-management-summary::-webkit-details-marker { + display: none; +} + +.os-settings__theme-management-summary::after { + position: absolute; + inset: 50% 18px auto auto; + width: 8px; + height: 8px; + border-inline-end: 2px solid currentColor; + border-block-end: 2px solid currentColor; + content: ""; + transform: translateY( -70% ) rotate( 45deg ); + transition: transform 0.16s ease; +} + +.os-settings__theme-management[ open ] + > .os-settings__theme-management-summary::after { + transform: translateY( -25% ) rotate( 225deg ); +} + +.os-settings__theme-management-summary:focus-visible { + outline: 2px solid var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + outline-offset: -3px; +} + +.os-settings__theme-management-summary strong, +.os-settings__theme-management-summary span { display: block; - /* Same reason as the other two dropzones above: the plain border - token is the card's own colour and vanishes on it. */ - border: 2px dashed var( --os-ui-border-strong, #c3c4c7 ); +} + +.os-settings__theme-management-summary > span > span { + margin-top: 2px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 11px; +} + +.os-settings__theme-management-body { + padding: 16px; + border-top: 1px solid var( --os-ui-border, #dcdcde ); +} + +.os-settings__theme-upload { + overflow: hidden; + background: var( --os-ui-surface, #fff ); + border: 2px dashed var( --os-ui-border-strong, #a7aaad ); border-radius: 10px; - background: transparent; transition: border-color 0.15s ease, background-color 0.15s ease; } .os-settings__theme-upload-label { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: 42px minmax( 0, 1fr ) auto; align-items: center; - justify-content: center; - gap: 6px; - /* Matches a theme card: 16:10 preview plus its name and meta. */ - min-height: 100%; - padding: 16px; - color: var( --os-ui-fg-muted, #50575e ); + gap: 12px; + min-height: 84px; + padding: 12px 14px; cursor: pointer; - text-align: center; +} + +.os-settings__theme-file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset( 50% ); + white-space: nowrap; +} + +.os-settings__theme-upload:focus-within { + outline: 2px solid var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + outline-offset: 2px; } .os-settings__theme-upload:hover, .os-settings__theme-upload--dragover { - border-color: var( --wp-admin-theme-color, #2271b1 ); - background-color: rgba( 34, 113, 177, 0.06 ); + border-color: var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + background: color-mix( + in srgb, + var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ) 6%, + var( --os-ui-surface, #fff ) + ); } .os-settings__theme-upload--dragover { border-style: solid; } -.os-settings__theme-upload--busy - .os-settings__theme-upload-label { +.os-settings__theme-upload--busy .os-settings__theme-upload-label { cursor: progress; opacity: 0.7; } -.os-settings__theme-upload-plus { +.os-settings__theme-upload-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var( --os-ui-accent, var( --wp-admin-theme-color, #2271b1 ) ); + background: var( --os-ui-surface-elevated, #f6f7f7 ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 9px; font-size: 22px; + font-weight: 400; line-height: 1; } + +.os-settings__theme-upload-copy, +.os-settings__theme-upload-copy span { + display: block; + min-width: 0; +} + +.os-settings__theme-upload-copy strong { + font-size: 12px; +} + +.os-settings__theme-upload-copy span { + margin-top: 2px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 10px; +} + +.os-settings__theme-upload-action { + padding: 6px 10px; + color: var( --os-ui-fg, #1d2327 ); + background: var( --os-ui-surface-elevated, #f6f7f7 ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 6px; + font-size: 10px; + font-weight: 650; +} + +.os-settings__theme-packages { + margin-top: 18px; +} + +.os-settings__theme-packages-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.os-settings__theme-packages-heading > span { + color: var( --os-ui-fg-muted, #50575e ); + font-size: 10px; +} + +.os-settings__theme-packages-empty { + margin: 0; + padding: 12px 0 2px; + color: var( --os-ui-fg-muted, #50575e ); +} + +.os-settings__theme-package-list { + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var( --os-ui-border, #dcdcde ); +} + +.os-settings__theme-package-list li { + display: flex; + align-items: center; + gap: 11px; + min-height: 64px; + padding: 9px 0; + border-bottom: 1px solid var( --os-ui-border, #dcdcde ); +} + +.os-settings__theme-package-copy { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.os-settings__theme-package-copy strong, +.os-settings__theme-package-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.os-settings__theme-package-copy span { + color: var( --os-ui-fg-muted, #50575e ); + font-size: 10px; +} + +@container os-themes ( max-width: 650px ) { + .os-settings__theme-stage { + grid-template-columns: 1fr; + } + + .os-settings__theme-stage-preview { + min-height: 0; + aspect-ratio: 16 / 9; + border-inline-end: 0; + border-bottom: 1px solid var( --os-ui-border, #dcdcde ); + } + + .os-settings__theme-stage-copy { + padding: 20px; + } +} + +@container os-themes ( max-width: 470px ) { + .os-settings__theme-grid { + grid-template-columns: 1fr; + } + + .os-settings__theme-upload-label { + grid-template-columns: 42px minmax( 0, 1fr ); + } + + .os-settings__theme-upload-action { + display: none; + } + + .os-settings__theme-artwork--manage { + display: none; + } +} + +@media ( prefers-reduced-motion: reduce ) { + .os-settings__theme-choice-card, + .os-settings__theme-management-summary::after { + transition: none; + } + + .os-settings__theme-choice:hover .os-settings__theme-choice-card { + transform: none; + } +} diff --git a/assets/css/variables.css b/assets/css/variables.css index 39fbdbad..e81042ef 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -859,7 +859,23 @@ body.os-active { --os-ui-context-menu-bg: #1a1721; --os-ui-context-menu-separator-color: rgba(255, 251, 255, 0.1); --os-ui-flyout-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + /* + * A card at rest, and the same card under the pointer. The hover + * half has been here since ``; the resting half is its + * missing sibling, added because the Themes library needed a + * settled elevation and inventing one at the use site would have + * put it out of reach of every theme. + */ + --os-ui-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); --os-ui-card-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.45); + /* + * The badge a card wears ON its preview art, which is why this is + * its own name rather than the resting shadow above: it is read + * against arbitrary imagery, not against the panel, so it wants a + * heavier shadow than the card it sits on. Sharing one token + * collapsed the two the moment the palette declared it. + */ + --os-ui-card-mark-shadow: 0 3px 10px rgba(0, 0, 0, 0.5); --os-ui-ribbon-shadow: 0 2px 4px rgba(0, 0, 0, 0.45); /* Tables. */ diff --git a/assets/desktop-themes/legacy/theme.json b/assets/desktop-themes/legacy/theme.json index f3c6bbc3..b2cb4436 100644 --- a/assets/desktop-themes/legacy/theme.json +++ b/assets/desktop-themes/legacy/theme.json @@ -220,6 +220,8 @@ "--os-ui-card-padding-compact": "10px", "--os-ui-card-radius": "12px", "--os-ui-card-radius-compact": "8px", + "--os-ui-card-mark-shadow": "0 3px 10px rgba( 0, 0, 0, 0.3 )", + "--os-ui-card-shadow": "0 3px 10px rgba( 0, 0, 0, 0.07 )", "--os-ui-card-shadow-hover": "0 4px 16px rgba( 0, 0, 0, 0.08 )", "--os-ui-card-shadow-selected": "0 0 0 1px #2271b1 inset", "--os-ui-cat-check-border": "#8c8f94", diff --git a/docs/desktop-themes.md b/docs/desktop-themes.md index f176af3d..6e3c244a 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -1081,11 +1081,12 @@ activates it — and never again.** - **A later change by the user always wins.** Pick the theme, put the dock back to compact, re-pick the theme — it stays compact. -The way back is the user's to take: **OpenStation Preferences → Themes** shows an -**Apply <theme>'s recommended layout and effects** button for the -active theme when it recommends something, and that is the only path -that applies a recommendation a second time. It sets the settings and -nothing else — the dock resizing under the cursor is the feedback. +The way back is the user's to take: **OpenStation Preferences → Themes** shows a +**Restore recommended layout & effects** button on the stage that +carries the active theme, when that theme recommends something, and +that is the only path that applies a recommendation a second time. It +sets the settings and nothing else — the dock resizing under the +cursor is the feedback. This is the same posture as [wallpapers](#it-is-a-pick-not-an-act), for the same reason. Dock size and layout are stored user @@ -1636,9 +1637,13 @@ from an empty manifest. ## Installing and activating -**Install:** OpenStation Preferences → Themes → drop a `.zip` on the upload tile. -Requires `manage_options` by default (filterable via -`openstation_desktop_theme_upload_capability`). +**Install:** OpenStation Preferences → Themes → **Manage theme +packages** → drop a `.zip` on the upload area, or choose one from your +computer. Installing and removing packages is site-wide work, so the +whole drawer — and it alone — requires `manage_options` by default +(filterable via `openstation_desktop_theme_upload_capability`). +Choosing which installed theme to *wear* is personal and needs no +capability at all; that is the library above the drawer. **Window controls.** These are title-bar chrome, so they follow the title bar's own colours rather than the body palette, and each focus @@ -1690,15 +1695,25 @@ including users who cannot upload. The library is site-wide; activation is per-user, stored as `desktopTheme` in the `desktop_mode_os_settings` user meta. -The first card in the grid is **OpenStation** — the shell's own look, -stored as the empty string. It is not a theme in the registry (its -palette is `assets/css/variables.css`, not a manifest), but the picker -treats it as a peer of everything beside it: it carries the same -"Apply …'s recommended layout and effects" button, and what it -recommends is the accent its palette was drawn against (Pulse) and the -layout it was drawn for (`classic`). - -The switch is live: no reload. The stylesheet swaps, the shell +The first choice in the library is **OpenStation** — the shell's own +look, stored as the empty string. It is not a theme in the registry +(its palette is `assets/css/variables.css`, not a manifest), but the +picker treats it as a peer of everything beside it: it gets the same +stage and the same "Restore recommended layout & effects" button when +it is the one being worn, and what it recommends is the accent its +palette was drawn against (Pulse) and the layout it was drawn for +(`classic`). + +The library is a radio group, but **activation is manual**: arrow keys +move focus between choices, and Space, Enter or a click is what +actually switches theme. Selection deliberately does not follow focus. +Switching is not editing a preference — it swaps the stylesheet, +repaints every themed icon, and on a first wearing seeds that theme's +recommendations over the user's own arrangement — so arrowing across +the library on the way to one theme must not fire that for every +theme it passes. + +The switch itself is live: no reload. The stylesheet swaps, the shell attribute and body class flip, and every themed icon repaints. On a fresh page load PHP stamps the attribute, prints the body class, and enqueues the stylesheet before the shell script runs, so there is no @@ -1706,9 +1721,9 @@ flash of the default palette. If the theme ships [`recommendedOsSettings`](#recommended-os-settings), the user's first -activation of it also seeds those preferences — once — and the tab -grows an **Apply <theme>'s recommended layout** button for going -back to them later. +activation of it also seeds those preferences — once — and the stage +grows a **Restore recommended layout & effects** button for going back +to them later. ### From JavaScript diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index 202558a0..379a3601 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -3078,7 +3078,7 @@ Whatever wins, `edgeLag` is scaled by the same ratio, so the edge band keeps its - **It replays on every load edge** the spinner replays on — a reload, an in-window navigation, a tab switch — not only on first open. - **`prefers-reduced-motion` skips the animation** and uncovers the content directly. Same for environments without the Web Animations API. - **The colours are theme tokens**: `--os-window-reveal-surface` (white) and `--os-window-reveal-edge` (`transparent` — no edge). A def can override them with `surfaceColor` / `edgeColor`, or per layer with `layers[].color`, but should not unless the paint is the point. `obturator` is the only built-in that does, and only per layer: its six leaves have to differ from one another or the mechanism reads as a single shape. -- **A desktop theme can recommend a reveal**, via `recommendedOsSettings.windowReveal` and `recommendedOsSettings.windowRevealDuration` — applied once on first activation, or on demand from the Themes tab's "Apply recommended layout and effects" button. +- **A desktop theme can recommend a reveal**, via `recommendedOsSettings.windowReveal` and `recommendedOsSettings.windowRevealDuration` — applied once on first activation, or on demand from the Themes tab's "Restore recommended layout & effects" button. - **Registration is JS-only.** Unlike unfocus effects, there is no `openstation_register_window_reveal_script()` PHP companion yet, so a reveal registered by a plugin activated mid-session appears in the selector only after a reload — and a deactivated plugin's reveal stays listed until a reload too (`owner` is recorded, but nothing sweeps by it on deactivation yet). Same known gap as palettes. The raw `os.window-reveals` JS filter receives the registry array on every read, mirroring `os.unfocus-effects` — use it to reorder, remove, or conditionally swap reveals. The user's selection persists in the `windowReveal` OS-settings key (reveal id or `'none'`, the default — reveals are opt-in), readable via `getOsSettings().windowReveal`. An unknown id (a deactivated plugin's reveal still named in user meta) resolves to no reveal rather than to a substitute, and starts working again the moment that plugin re-registers it. diff --git a/src/settings/sections/themes.ts b/src/settings/sections/themes.ts index 7d91afe1..1d6cc572 100644 --- a/src/settings/sections/themes.ts +++ b/src/settings/sections/themes.ts @@ -1,20 +1,21 @@ /** * OS Settings → Themes. * - * A card grid of every desktop theme in the site's library, plus - * "System default". Picking is per-user and open to everyone; the - * upload tile and the per-card delete button appear only for users - * who hold the theme-management capability - * (`canManageDesktopThemes`). + * The tab has two jobs with very different audiences: * - * Code-registered themes (`source: 'code'`) never get a delete - * button: there is no file to remove and the REST route rightly - * 404s on them, so offering the control would only ever produce an - * error. A plugin that ships a theme takes it away by unregistering - * it — see the built-in "Legacy" theme. + * - choosing a look is personal, instant, and available to everyone; + * - installing or removing packages is site-wide administration. + * + * Keep those jobs visually separate. The current look gets a large + * stage, the personal library is a preview-first radio group, and the + * administrative tools live in their own disclosure at the bottom. + * + * Code-registered themes (`source: 'code'`) never get a delete action: + * there is no file to remove and the REST route rightly 404s on them. + * A plugin that ships a theme takes it away by unregistering it. */ -import { __, sprintf } from '../../i18n'; +import { __, _n, sprintf } from '../../i18n'; import { html, render } from '../../ui/core'; import { osConfirm } from '../../ui/components/os-confirm-dialog/os-confirm-dialog'; import { @@ -34,25 +35,30 @@ import { } from '../theme-recommendations'; import type { SettingsCtx } from '../types'; -/** Sentinel card id for "no theme". */ +/** Sentinel id for the shell's built-in look. */ const SYSTEM_DEFAULT = ''; -/** - * What the "no theme" card is called. - * - * Not a translated string: it is the name of the shell's own look, the - * same way "Desktop Mode (Legacy)" is the name of the theme beside it, - * and a product name does not get translated. It reads as a peer of - * the themes in the grid because that is exactly what it is — one - * palette among several, and the one the plugin ships wearing. - */ +/** Product name shown beside installed themes. */ const SYSTEM_DEFAULT_NAME = 'OpenStation'; -/** - * Initials shown on a theme card that ships no preview image. - * Same idea as the letter-badge icon fallback: something - * recognisable and stable beats an empty rectangle. - */ +/** The built-in look has no manifest, so its library copy lives here. */ +const SYSTEM_DEFAULT_DESCRIPTION = __( + 'The original OpenStation look: graphite surfaces, Pulse accents, and the built-in icon set.', +); + +/** Honest copy when a package omits its optional description. */ +const THEME_FALLBACK_DESCRIPTION = __( + 'A complete desktop look for OpenStation.', +); + +function descriptionFor( theme: DesktopThemeEntry | null ): string { + if ( theme === null ) { + return SYSTEM_DEFAULT_DESCRIPTION; + } + return theme.description || THEME_FALLBACK_DESCRIPTION; +} + +/** Stable fallback for a theme that ships no preview image. */ function initialsFor( name: string ): string { const words = name.trim().split( /\s+/ ).filter( Boolean ); if ( words.length === 0 ) { @@ -64,15 +70,31 @@ function initialsFor( name: string ): string { return words[ 0 ].slice( 0, 2 ).toUpperCase(); } +/** + * The shell falls back to the system look when a saved theme is no + * longer registered. Mirror that resolution in the picker so it never + * shows a desktop with no selected radio. + */ +function resolveActiveSlug( + themes: DesktopThemeEntry[], + savedSlug: string, +): string { + if ( + savedSlug !== SYSTEM_DEFAULT && + themes.some( ( theme ) => theme.slug === savedSlug ) + ) { + return savedSlug; + } + return SYSTEM_DEFAULT; +} + export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { const host = document.createElement( 'div' ); host.className = 'os-settings__themes'; - /** Last error surfaced by an upload or delete, if any. */ let errorText = ''; - /** True while an upload is in flight — disables the tile. */ - let busy = false; - + let uploadBusy = false; + let deletingSlug = ''; const canManage = !! ctx.config.canManageDesktopThemes; const pick = ( id: string ): void => { @@ -80,33 +102,66 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { return; } ctx.state.desktopTheme = id; - // First activation only. `applyThemeRecommendations` no-ops for - // a theme this user has already worn, which is what stops a - // theme from ever undoing a preference the user set afterwards. + // Recommendations seed only on first activation. Rewearing a + // theme never undoes preferences the user changed afterwards. applyThemeRecommendations( ctx.state, id ); ctx.save(); - // `apply()` calls `applyDesktopTheme()`, which swaps the - // stylesheet, flips the shell attribute + body class, and - // fires the change event the shell listens on to repaint - // every themed icon. One call covers the whole switch. ctx.apply(); paint(); }; /** - * "Apply recommended layout and effects" — the deliberate way back - * to the author's intended presentation after the user has moved - * things around. The only path that re-applies a recommendation. + * Arrow keys move focus through the library WITHOUT selecting. * - * "and effects" is not decoration: a theme may recommend the - * window-reveal style and its speed alongside the layout keys, and - * a label that named only the layout would understate what the - * button is about to change. + * A native radio group selects as focus moves, and here selecting + * is not editing a preference — it is an action. `pick()` swaps + * the desktop stylesheet, repaints every themed icon, and the + * first time a user wears a theme it seeds that theme's + * recommended dock size, layout and effects over whatever they had + * arranged, then records the slug so the offer is never made + * again. Arrowing past six themes on the way to the seventh would + * fire all of that six times and burn six one-shot seeds, silently + * and unrecoverably. * - * It just sets the settings. The dock resizing and the layout - * moving IS the feedback; a notice on top of a visible change is - * noise. + * So the group is manual-activation: arrows move, Space or Enter + * commits, a click commits. That is the bargain every other picker + * in this panel already makes — `` tiles are buttons + * inside a `role="radiogroup"` and have never selected on focus. */ + const ARROW_STEPS: Record< string, number > = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, + }; + + const onLibraryKeydown = ( e: KeyboardEvent ): void => { + const radios = Array.from( + ( e.currentTarget as HTMLElement ).querySelectorAll< HTMLInputElement >( + '.os-settings__theme-choice-input', + ), + ); + const from = radios.indexOf( e.target as HTMLInputElement ); + if ( from === -1 ) { + return; + } + // Enter is inert on a radio outside a form, so it has to be + // wired by hand to be the second half of "Space or Enter". + if ( e.key === 'Enter' ) { + e.preventDefault(); + pick( radios[ from ].value ); + return; + } + const step = ARROW_STEPS[ e.key ]; + if ( step === undefined ) { + return; + } + // Without this the browser's own radio handling selects as it + // moves, which is the whole thing we are here to prevent. + e.preventDefault(); + radios[ ( from + step + radios.length ) % radios.length ].focus(); + }; + const applyRecommended = ( themeSlug: string ): void => { const applied = applyThemeRecommendations( ctx.state, themeSlug, { force: true, @@ -120,28 +175,32 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { }; const doUpload = async ( file: File ): Promise< void > => { - if ( busy ) { + if ( uploadBusy ) { return; } - busy = true; + uploadBusy = true; errorText = ''; paint(); try { const entry = await uploadDesktopTheme( ctx.config, file ); - // Insert directly rather than waiting for the next payload - // refresh — the theme the admin just uploaded should be - // pickable the moment the spinner stops. + // Make a successful upload pickable immediately rather than + // waiting for the next server-payload refresh. upsertDesktopTheme( entry ); } catch ( err ) { errorText = - err instanceof Error ? err.message : __( 'That theme could not be installed.' ); + err instanceof Error + ? err.message + : __( 'That theme could not be installed.' ); } finally { - busy = false; + uploadBusy = false; paint(); } }; const doDelete = async ( theme: DesktopThemeEntry ): Promise< void > => { + if ( deletingSlug !== '' ) { + return; + } const ok = await osConfirm( { title: __( 'Delete this theme?' ), message: sprintf( @@ -155,15 +214,15 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { if ( ! ok ) { return; } + + deletingSlug = theme.slug; errorText = ''; + paint(); try { await deleteDesktopTheme( ctx.config, theme.slug ); removeDesktopTheme( theme.slug ); - // Deleting the theme THIS user is wearing has to reset - // their selection too, or the shell would keep a - // stylesheet whose file no longer exists until reload. - // Other users are handled server-side: the enqueue path - // existence-checks on every request. + // Reset this user immediately when the deleted package was + // active. Other users resolve the missing package server-side. if ( ctx.state.desktopTheme === theme.slug ) { ctx.state.desktopTheme = SYSTEM_DEFAULT; ctx.save(); @@ -171,9 +230,13 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { } } catch ( err ) { errorText = - err instanceof Error ? err.message : __( 'That theme could not be deleted.' ); + err instanceof Error + ? err.message + : __( 'That theme could not be deleted.' ); + } finally { + deletingSlug = ''; + paint(); } - paint(); }; const onFileInput = ( e: Event ): void => { @@ -182,7 +245,7 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { if ( file ) { void doUpload( file ); } - // Clear so re-picking the same file fires `change` again. + // Re-picking the same archive should fire change again. input.value = ''; }; @@ -197,85 +260,167 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { } }; - const themeCard = ( theme: DesktopThemeEntry ) => { - const selected = ctx.state.desktopTheme === theme.slug; - return html`
- - ${ canManage && theme.source !== 'code' - ? html`` - : '' } -
`; + + + + `; }; - const systemCard = () => { - const selected = ctx.state.desktopTheme === SYSTEM_DEFAULT; - return html`
- -
`; + ${ name } + +

+ ${ description } +

+
+ ${ theme?.author + ? html`${ sprintf( + /* translators: %s: theme author. */ + __( 'By %s' ), + theme.author, + ) }` + : html`${ __( 'OpenStation original' ) }` } + ${ theme?.version + ? html`${ sprintf( + /* translators: %s: theme version string. */ + __( 'Version %s' ), + theme.version, + ) }` + : '' } +
+ ${ recommendationAvailable + ? html`
+ applyRecommended( activeSlug ) } + >${ __( 'Restore recommended layout & effects' ) } +
` + : '' } + + `; }; - const uploadTile = () => html`
html`
{ @@ -291,93 +436,143 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { } } @drop=${ onDrop } > -
`; - /** - * The "restore the author's arrangement" row. Shown only for the - * theme the user is currently wearing, and only when it actually - * recommends something this shell can apply — a recommendation - * naming a dock rail renderer no plugin registered resolves to - * nothing, and an unusable button is worse than no button. - */ - /** - * The row is not only for installed themes: the system default is - * a palette with an arrangement of its own — the accent it was - * drawn against — and it needs the same way back after the user - * has moved things around. Its recommendations live in - * `theme-recommendations.ts` rather than in a manifest, because it - * has no manifest; everything downstream treats it identically. - */ - const recommendationRow = ( themes: DesktopThemeEntry[] ) => { - const activeSlug = ctx.state.desktopTheme; - if ( ! hasApplicableThemeRecommendations( activeSlug ) ) { - return ''; - } - const name = - activeSlug === SYSTEM_DEFAULT - ? SYSTEM_DEFAULT_NAME - : themes.find( ( theme ) => theme.slug === activeSlug )?.name; - if ( name === undefined ) { - return ''; - } - return html`
- applyRecommended( activeSlug ) } - >${ sprintf( - /* translators: %s: theme name. */ - __( 'Apply %s’s recommended layout and effects' ), - name, - ) } -
`; + const managementPanel = ( themes: DesktopThemeEntry[] ) => { + const removable = themes.filter( ( theme ) => theme.source === 'upload' ); + return html`
+ + + ${ __( 'Manage theme packages' ) } + ${ __( 'Install or remove themes for everyone on this site.' ) } + + +
+ ${ uploadControl() } +
+
+ ${ __( 'Removable packages' ) } + ${ sprintf( + /* translators: %d: number of removable theme packages. */ + _n( '%d theme', '%d themes', removable.length ), + removable.length, + ) } +
+ ${ removable.length === 0 + ? html`

+ ${ __( 'Themes installed from .zip files will appear here.' ) } +

` + : html`
    + ${ removable.map( + ( theme ) => html`
  • + ${ artwork( theme, 'manage' ) } + + ${ theme.name } + ${ theme.version + ? sprintf( + /* translators: %s: theme version string. */ + __( 'Version %s' ), + theme.version, + ) + : __( 'Installed package' ) } + + void doDelete( theme ) } + >${ deletingSlug === theme.slug + ? __( 'Removing…' ) + : __( 'Remove' ) } +
  • `, + ) } +
` } +
+
+
`; }; function paint(): void { const themes = listDesktopThemes(); + const activeSlug = resolveActiveSlug( themes, ctx.state.desktopTheme ); + const lookCount = themes.length + 1; + render( html` +
+ ${ __( 'Desktop themes' ) } +

+ ${ __( 'Change the whole station.' ) } +

+

+ ${ __( + 'Themes reshape the desktop, window chrome, dock, type, and icons. Your choice is personal and applies instantly.', + ) } +

+
${ errorText !== '' ? html`${ errorText }` : '' } - - + ${ activeStage( themes, activeSlug ) } +
+ + + ${ __( 'Your library' ) } + ${ __( 'Pick your next look' ) } + + ${ sprintf( + /* translators: %d: number of available desktop looks. */ + _n( '%d look', '%d looks', lookCount ), + lookCount, + ) } +
- ${ systemCard() } - ${ themes.map( ( theme ) => themeCard( theme ) ) } - ${ canManage ? uploadTile() : '' } + ${ themeChoice( null, activeSlug ) } + ${ themes.map( ( theme ) => + themeChoice( theme, activeSlug ), + ) }
- ${ recommendationRow( themes ) } - +
+ ${ canManage ? managementPanel( themes ) : '' } `, host, ); @@ -385,10 +580,8 @@ export function buildThemesSection( ctx: SettingsCtx ): HTMLElement { paint(); - // Repaint when the library changes underneath us — an admin - // activating a plugin that registers a theme, or the live-refresh - // payload landing. The `isConnected` guard keeps a closed Settings - // window's stale subscriber from painting into a detached tree. + // Repaint on plugin activation/deactivation or a server-payload + // refresh. Detached Settings windows unsubscribe on the next change. const unsubscribe = subscribeDesktopThemes( () => { if ( ! host.isConnected ) { unsubscribe(); diff --git a/tests/phpunit/tests/desktopThemesLegacy.php b/tests/phpunit/tests/desktopThemesLegacy.php index c52dc213..dcbcba78 100644 --- a/tests/phpunit/tests/desktopThemesLegacy.php +++ b/tests/phpunit/tests/desktopThemesLegacy.php @@ -186,7 +186,7 @@ public function test_the_snapshot_is_frozen() { $tokens = $this->manifest()['tokens']; $why = 'Legacy is a frozen snapshot — mint a new theme instead of moving it.'; - $this->assertCount( 463, $tokens, $why ); + $this->assertCount( 465, $tokens, $why ); foreach ( array( '--os-bg' => 'linear-gradient( 135deg, #1d2327 0%, #2c3338 50%, #1d2327 100% )', '--os-titlebar-bg' => '#f0f0f1', diff --git a/tests/vitest/os-settings-themes-tab.test.ts b/tests/vitest/os-settings-themes-tab.test.ts new file mode 100644 index 00000000..851bc696 --- /dev/null +++ b/tests/vitest/os-settings-themes-tab.test.ts @@ -0,0 +1,273 @@ +/** + * OS Settings → Themes tab. + * + * The picker separates personal selection from site-wide package + * management. These tests pin that information architecture and the + * native radio interaction that switches the current look. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { setDesktopThemes } from '../../src/desktop-themes/registry'; +import type { DesktopThemeEntry } from '../../src/desktop-themes/types'; +import { buildThemesSection } from '../../src/settings/sections/themes'; +import { structuredDefaults } from '../../src/settings/state'; +import type { SettingsCtx } from '../../src/settings/types'; + +function theme( + overrides: Partial< DesktopThemeEntry > = {}, +): DesktopThemeEntry { + return { + id: 'signal-garden', + slug: 'signal-garden', + name: 'Signal Garden', + version: '1.2.0', + author: 'OpenStation', + description: 'A quiet greenhouse built for long writing sessions.', + previewUrl: 'https://example.test/signal-garden.png', + cssUrl: 'https://example.test/signal-garden.css', + cssText: '', + tokens: {}, + fonts: [], + icons: {}, + iconColors: {}, + recommendedOsSettings: {}, + installedAt: 1, + source: 'upload', + ...overrides, + }; +} + +function ctx( + desktopTheme = '', + canManageDesktopThemes = false, +): SettingsCtx & { save: ReturnType< typeof vi.fn >; apply: ReturnType< typeof vi.fn > } { + return { + state: { ...structuredDefaults(), desktopTheme }, + config: { + canManageDesktopThemes, + desktopThemesUrl: 'https://example.test/desktop-themes', + }, + save: vi.fn(), + apply: vi.fn(), + } as unknown as SettingsCtx & { + save: ReturnType< typeof vi.fn >; + apply: ReturnType< typeof vi.fn >; + }; +} + +function mount( settingsCtx: SettingsCtx ): HTMLElement { + const el = buildThemesSection( settingsCtx ); + document.body.appendChild( el ); + return el; +} + +describe( 'OS Settings — Themes tab', () => { + beforeEach( () => { + document.body.replaceChildren(); + setDesktopThemes( [] ); + } ); + + afterEach( () => { + document.body.replaceChildren(); + // Detached subscribers remove themselves on this change. + setDesktopThemes( [] ); + } ); + + test( 'stages the active theme and uses its real library copy', () => { + setDesktopThemes( [ theme() ] ); + const el = mount( ctx( 'signal-garden' ) ); + + expect( + el.querySelector( '.os-settings__theme-stage-name' )?.textContent?.trim(), + ).toBe( 'Signal Garden' ); + expect( + el.querySelector( '.os-settings__theme-stage-description' )?.textContent, + ).toContain( 'quiet greenhouse' ); + expect( + el.querySelector< HTMLImageElement >( '.os-settings__theme-stage img' ) + ?.src, + ).toBe( 'https://example.test/signal-garden.png' ); + } ); + + test( 'renders the library as one native radio group', () => { + setDesktopThemes( [ theme(), theme( { slug: 'paper-sun', name: 'Paper Sun' } ) ] ); + const el = mount( ctx( 'paper-sun' ) ); + const radios = Array.from( + el.querySelectorAll< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]', + ), + ); + + expect( radios ).toHaveLength( 3 ); + expect( radios.find( ( radio ) => radio.checked )?.value ).toBe( + 'paper-sun', + ); + expect( el.querySelector( '.os-settings__theme-count' )?.textContent ).toContain( + '3 looks', + ); + } ); + + test( 'switching a radio applies and saves the chosen look', () => { + setDesktopThemes( [ theme() ] ); + const settingsCtx = ctx(); + const el = mount( settingsCtx ); + const radio = Array.from( + el.querySelectorAll< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]', + ), + ).find( ( input ) => input.value === 'signal-garden' )!; + + radio.checked = true; + radio.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + expect( settingsCtx.state.desktopTheme ).toBe( 'signal-garden' ); + expect( settingsCtx.save ).toHaveBeenCalledOnce(); + expect( settingsCtx.apply ).toHaveBeenCalledOnce(); + expect( + el.querySelector( '.os-settings__theme-stage-name' )?.textContent?.trim(), + ).toBe( 'Signal Garden' ); + } ); + + test( 'shows the system look when a saved package is missing', () => { + const el = mount( ctx( 'no-longer-installed' ) ); + const selected = el.querySelector< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]:checked', + ); + + expect( selected?.value ).toBe( '' ); + expect( + el.querySelector( '.os-settings__theme-stage-name' )?.textContent?.trim(), + ).toBe( 'OpenStation' ); + } ); + + test( 'does not describe an undocumented package as the system theme', () => { + setDesktopThemes( [ theme( { description: '' } ) ] ); + const el = mount( ctx( 'signal-garden' ) ); + + expect( + el.querySelector( '.os-settings__theme-stage-description' )?.textContent, + ).toContain( 'A complete desktop look' ); + expect( + el.querySelector( '.os-settings__theme-stage-description' )?.textContent, + ).not.toContain( 'original OpenStation look' ); + } ); + + test( 'arrow keys move focus through the library without switching theme', () => { + setDesktopThemes( [ theme(), theme( { slug: 'paper-sun', name: 'Paper Sun' } ) ] ); + const settingsCtx = ctx(); + const el = mount( settingsCtx ); + const radios = Array.from( + el.querySelectorAll< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]', + ), + ); + radios[ 0 ].focus(); + + const arrow = new KeyboardEvent( 'keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + } ); + radios[ 0 ].dispatchEvent( arrow ); + + // Cancelled, or the browser's own radio handling would select + // as it moves — which on this group means swapping the desktop + // stylesheet and seeding the theme's recommendations. + expect( arrow.defaultPrevented ).toBe( true ); + expect( document.activeElement ).toBe( radios[ 1 ] ); + expect( settingsCtx.state.desktopTheme ).toBe( '' ); + expect( settingsCtx.save ).not.toHaveBeenCalled(); + expect( settingsCtx.apply ).not.toHaveBeenCalled(); + + // And it wraps, the way a radio group's arrows always have. + const back = new KeyboardEvent( 'keydown', { + key: 'ArrowLeft', + bubbles: true, + cancelable: true, + } ); + radios[ 0 ].focus(); + radios[ 0 ].dispatchEvent( back ); + expect( document.activeElement ).toBe( radios[ radios.length - 1 ] ); + } ); + + test( 'Enter commits the focused choice', () => { + setDesktopThemes( [ theme() ] ); + const settingsCtx = ctx(); + const el = mount( settingsCtx ); + const target = Array.from( + el.querySelectorAll< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]', + ), + ).find( ( input ) => input.value === 'signal-garden' )!; + + target.dispatchEvent( + new KeyboardEvent( 'keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + } ), + ); + + expect( settingsCtx.state.desktopTheme ).toBe( 'signal-garden' ); + expect( settingsCtx.apply ).toHaveBeenCalledOnce(); + expect( + el.querySelector< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]:checked', + )?.value, + ).toBe( 'signal-garden' ); + } ); + + test( 'keeps a radio checked after the worn theme leaves the library', () => { + setDesktopThemes( [ theme() ] ); + const el = mount( ctx() ); + const radios = () => + Array.from( + el.querySelectorAll< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]', + ), + ); + + // Both clicks set each input's dirty-checkedness flag, which is + // what makes the `checked` ATTRIBUTE stop reflecting to the + // property. The binding has to be the property. + radios()[ 0 ].click(); + radios()[ 1 ].click(); + + // The package is deleted, or its plugin is deactivated. + setDesktopThemes( [] ); + + expect( radios().filter( ( radio ) => radio.checked ) ).toHaveLength( 1 ); + expect( + el.querySelector< HTMLInputElement >( + 'input[name="openstation-desktop-theme"]:checked', + )?.value, + ).toBe( '' ); + } ); + + test( 'keeps package management admin-only and separate from choices', () => { + setDesktopThemes( [ + theme(), + theme( { + id: 'plugin-theme', + slug: 'plugin-theme', + name: 'Plugin Theme', + source: 'code', + } ), + ] ); + + expect( + mount( ctx( '', false ) ).querySelector( + '.os-settings__theme-management', + ), + ).toBeNull(); + + const adminEl = mount( ctx( '', true ) ); + const removableNames = Array.from( + adminEl.querySelectorAll( '.os-settings__theme-package-copy strong' ), + ).map( ( node ) => node.textContent?.trim() ); + + expect( removableNames ).toEqual( [ 'Signal Garden' ] ); + expect( + adminEl.querySelector( '.os-settings__theme-file-input' ), + ).not.toBeNull(); + } ); +} );