diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 203bec50f..696d04255 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -338,14 +338,25 @@ const VTL_SHADOW_CSS = ` Measured before this change: 44px rows with an 8px radius. min-height:48px the Material list-item height. min-height, not height, so a wrapping label still grows instead of clipping. - border-radius:999px a full pill, the YouTube active-row shape. Any value past half the - height renders identically, so 999 states "fully round" without - hard-coding a height that may change later. + border-radius var(--vtl-radius,8px) — SUPERSEDED THE FULL PILL (2026-09-23). + The 999px stadium above was a real instruction ("imitate the YouTube + active row") and it shipped. The Driver then measured it against the + rest of the product and reversed it: "the roundedness of the tabs is a + little bit too round because there is nowhere else in the app that has + that same roundedness. Even the collapse button is not that round... + it looks more material UI to have a more rounded tab than a less + circular tab." He is right on the facts — .vtl-collapse is 8px, the + filter toggles in @map/components/search are 8px, and 8px is already + the most common radius in this file. A 999px radius on a 48px row is a + stadium, which is why it reads as foreign beside everything else. + ★ DO NOT "RESTORE" THE PILL on the strength of the YouTube rationale + above — that rationale is kept because it is true history, not because + it is current. The 48px Material row height is unaffected and stays. NOT VENDORED: the card's law is to imitate the metrics in our own CSS, never to add Quasar or MUI. Same clone-dont-edit rule the library follows everywhere else. THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay byte-identical — edit both or neither. */ -.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:999px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; } +.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; } .vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; } .vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; } .vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; } @@ -357,7 +368,7 @@ const VTL_SHADOW_CSS = ` .vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); } .vtl-tab-text { display:flex; flex-direction:column; min-width:0; } .vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; } -.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; } +.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:var(--vtl-radius,8px); padding:2px 5px; } .vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; } .vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); } @@ -760,14 +771,25 @@ export default defineComponent({ Measured before this change: 44px rows with an 8px radius. min-height:48px the Material list-item height. min-height, not height, so a wrapping label still grows instead of clipping. - border-radius:999px a full pill, the YouTube active-row shape. Any value past half the - height renders identically, so 999 states "fully round" without - hard-coding a height that may change later. + border-radius var(--vtl-radius,8px) — SUPERSEDED THE FULL PILL (2026-09-23). + The 999px stadium above was a real instruction ("imitate the YouTube + active row") and it shipped. The Driver then measured it against the + rest of the product and reversed it: "the roundedness of the tabs is a + little bit too round because there is nowhere else in the app that has + that same roundedness. Even the collapse button is not that round... + it looks more material UI to have a more rounded tab than a less + circular tab." He is right on the facts — .vtl-collapse is 8px, the + filter toggles in @map/components/search are 8px, and 8px is already + the most common radius in this file. A 999px radius on a 48px row is a + stadium, which is why it reads as foreign beside everything else. + ★ DO NOT "RESTORE" THE PILL on the strength of the YouTube rationale + above — that rationale is kept because it is true history, not because + it is current. The 48px Material row height is unaffected and stays. NOT VENDORED: the card's law is to imitate the metrics in our own CSS, never to add Quasar or MUI. Same clone-dont-edit rule the library follows everywhere else. THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay byte-identical — edit both or neither. */ -.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:999px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; } +.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; } .vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; } .vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; } .vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; } @@ -779,7 +801,7 @@ export default defineComponent({ .vtl-tab.active .vtl-tab-fallback { color:var(--vtl-accent); } .vtl-tab-text { display:flex; flex-direction:column; min-width:0; } .vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; } -.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; } +.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:var(--vtl-radius,8px); padding:2px 5px; } .vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; } .vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); } diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 4175cd048..2d34ada81 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -495,9 +495,41 @@ export default defineComponent({ out.sort((a, b) => b.cap - a.cap || a.bundle.localeCompare(b.bundle) || a.label.localeCompare(b.label)) return out }, + // ★ WHICH TAB OPENS, in priority order. Driver: "when the plugin studio opens, it doesn't by + // default select the first tab. It tells you to select an element from the list. It just needs + // to auto select the 1st tab and remember using local storage what tab I was on. If I reload + // the screen so it will go back to the previously selected tab." + // + // 1. the URL hash — an explicit, shareable request always wins over a remembered one + // 2. localStorage — what you were last looking at, so a reload is not a fresh start + // 3. the first row — never leave the pane empty + // + // Called once after the manifest lands, so `rows` is populated and every candidate can be + // checked against it. A remembered key whose bundle was renamed or removed simply fails that + // check and falls through to the first row — a stale key must never render an empty pane, which + // is the very state this replaces. route() { const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '') - if (h && this.rows.some(r => r.key === h)) this.select(h) + if (h && this.rows.some(r => r.key === h)) { this.select(h); return } + const remembered = this.readLastTab() + if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return } + if (this.rows.length) this.select(this.rows[0].key) + }, + // Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would + // otherwise fight over a single key and each reload would yank the other's tab. + // ★ EVERY ACCESS IS GUARDED: localStorage THROWS on access in private mode and when storage is + // disabled by policy, and an exception here would abort setup() and silently remove the whole + // subtree — the studio would render blank rather than degrade. Remembering a tab is a + // convenience; it must never be able to break the shell. + storageKey() { + const path = (typeof location !== 'undefined' && location.pathname) || '' + return 'dx-studio-tab:' + path + }, + readLastTab() { + try { return window.localStorage.getItem(this.storageKey()) || '' } catch (_) { return '' } + }, + writeLastTab(key) { + try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ } }, select(key) { const prevKey = this.activeKey @@ -513,6 +545,9 @@ export default defineComponent({ } const row = this.rows.find(r => r.key === key) if (!row) return + // Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise + // one stray select() would poison the next reload with a key that fails route()'s check. + this.writeLastTab(key) // A heavy row's slot only exists after the v-if subtree renders, so wait a tick before // mounting into it — on the first activation the ref is null at this point. if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })