From d4a8184ad7bb16dcc94fecc2abd1d49689b7d672 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:03:15 +0000 Subject: [PATCH] Match the tab radius to the rest of the app, and open on the tab you were last on The vertical tabs were a 999px stadium on a 48px row. That was a real earlier instruction -- imitate the YouTube active row -- and it shipped, but measured against the product it is the only thing shaped that way: the collapse button is 8px, the filter toggles are 8px, and 8px is already the most common radius in the layout file. They now read var(--vtl-radius,8px), so a consumer has one knob and the three surfaces cannot drift apart. The 48px Material row height is untouched. The YouTube rationale stays in the comment as history with an explicit note not to restore the pill from it, because a superseded rationale that still reads as current is how a reversed decision gets quietly undone. The .vtl-cap badge keeps its pill. The complaint was about the ratio of radius to size -- a 16px chip at 999px is a chip, a 48px row at 999px is a stadium. The studio opened on nothing and said "Choose an element from the list." It now resolves a tab in priority order: the URL hash, since an explicit shareable request should beat a remembered one; then localStorage, so a reload is not a fresh start; then the first row, so the pane is never empty. This lives in the studio rather than the layout because VerticalTabsLayout is controlled via v-model:active and deliberately holds no selection state. Storage is keyed by pathname so two shells on one origin do not fight over one key, every access is wrapped because localStorage throws in private mode and an exception in setup would blank the whole subtree, and the key is only written after the row is confirmed to exist so a bad key cannot poison the next load. Verified in the built shell on a settled tree: first load auto-selects with no hint and stores the key; clicking big-picture moves the selection and updates storage; a reload restores big-picture; and a deliberately poisoned key falls back to the first tab and rewrites itself rather than rendering an empty pane. --- library/layouts/VerticalTabsLayout.vue | 42 ++++++++++++++----- .../plugin-studio/plugin-studio.vue | 37 +++++++++++++++- 2 files changed, 68 insertions(+), 11 deletions(-) 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) })