diff --git a/package.json b/package.json index bd68dcdcb..824f99226 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,16 @@ "description": "Enable dbt docs collaboration.", "default": true }, + "dbt.showChangelogOnUpdate": { + "type": "boolean", + "description": "Show the What's New page automatically after the extension updates to a new minor or major version.", + "default": true + }, + "dbt.whatsNewManifestUrl": { + "type": "string", + "description": "Override the What's New manifest URL (advanced). Leave empty to use the default endpoint. '{version}' is replaced with the extension version.", + "default": "" + }, "dbt.disableQueryHistory": { "type": "boolean", "description": "Disable Query history and bookmarks", @@ -423,6 +433,11 @@ "title": "Query Results Debug Info", "category": "dbt Power User" }, + { + "command": "dbtPowerUser.showWhatsNew", + "title": "Show What's New", + "category": "dbt Power User" + }, { "command": "dbtPowerUser.resolveConversation", "title": "Resolve", diff --git a/src/commands/index.ts b/src/commands/index.ts index 5fccb494f..7f4e150bf 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -68,6 +68,7 @@ import { getFormattedDateTime, } from "../utils"; import { SQLLineagePanel } from "../webview_provider/sqlLineagePanel"; +import { WhatsNewPanel } from "../webview_provider/whatsNewPanel"; import { AltimateScan } from "./altimateScan"; import { BigQueryCostEstimate } from "./bigQueryCostEstimate"; import { RunModel } from "./runModel"; @@ -106,6 +107,7 @@ export class VSCodeCommands implements Disposable { private cteProfilerDecorationProvider: CteProfilerDecorationProvider, private cteCodeLensProvider: CteCodeLensProvider, private telemetry: TelemetryService, + private whatsNewPanel: WhatsNewPanel, ) { this.disposables.push( this.cteProfilerService, @@ -120,6 +122,9 @@ export class VSCodeCommands implements Disposable { commands.registerCommand("dbtPowerUser.installDbt", () => this.walkthroughCommands.installDbt(), ), + commands.registerCommand("dbtPowerUser.showWhatsNew", () => + this.whatsNewPanel.show("manual"), + ), commands.registerCommand("dbtPowerUser.runCurrentModel", () => { // `dbt run` on a singular test file is never meaningful; route it // to `dbt test --select ` instead. See #1720. diff --git a/src/dbtPowerUserExtension.ts b/src/dbtPowerUserExtension.ts index 5a41b84cd..19a3e1396 100644 --- a/src/dbtPowerUserExtension.ts +++ b/src/dbtPowerUserExtension.ts @@ -24,6 +24,7 @@ import { TelemetryEvents } from "./telemetry/events"; import { TreeviewProviders } from "./treeview_provider"; import { ValidationProvider } from "./validation_provider"; import { WebviewViewProviders } from "./webview_provider"; +import { WhatsNewPanel } from "./webview_provider/whatsNewPanel"; enum PromptAnswer { YES = "Yes", @@ -87,6 +88,7 @@ export class DBTPowerUserExtension implements Disposable { private commentProviders: CommentProviders, private notebookProviders: NotebookProviders, private mcpServer: DbtPowerUserMcpServer, + private whatsNewPanel: WhatsNewPanel, ) { this.disposables.push( this.dbtProjectContainer, @@ -177,6 +179,7 @@ export class DBTPowerUserExtension implements Disposable { await this.mcpServer.updateMcpExtensionApi(); this.dbtProjectContainer.setContext(context); this.dbtProjectContainer.initializeWalkthrough(); + this.whatsNewPanel.checkAndShowOnActivation(); await this.dbtProjectContainer.detectDBT(); await this.dbtProjectContainer.initializeDBTProjects(); await this.statusBars.initialize(); diff --git a/src/inversify.config.ts b/src/inversify.config.ts index faefa097a..6343fff59 100755 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -159,6 +159,7 @@ import { NewLineagePanel } from "./webview_provider/newLineagePanel"; import { OnboardingPanel } from "./webview_provider/onboardingPanel"; import { QueryResultPanel } from "./webview_provider/queryResultPanel"; import { SQLLineagePanel } from "./webview_provider/sqlLineagePanel"; +import { WhatsNewPanel } from "./webview_provider/whatsNewPanel"; export const container = new Container(); @@ -1626,6 +1627,7 @@ container context.container.get(CteProfilerDecorationProvider), context.container.get(CteCodeLensProvider), context.container.get(TelemetryService), + context.container.get(WhatsNewPanel), ); }) .inSingletonScope(); @@ -1856,6 +1858,23 @@ container }) .inSingletonScope(); +container + .bind(WhatsNewPanel) + .toDynamicValue((context) => { + return new WhatsNewPanel( + context.container.get(DBTProjectContainer), + context.container.get(AltimateRequest), + context.container.get(TelemetryService), + context.container.get(SharedStateService), + context.container.get("DBTTerminal"), + context.container.get(QueryManifestService), + context.container.get(UsersService), + context.container.get(AltimateAuthService), + context.container.get(AltimateCodeChatService), + ); + }) + .inSingletonScope(); + // Bind DbtPowerUserActionsCenter container .bind(DbtPowerUserActionsCenter) @@ -1901,6 +1920,7 @@ container context.container.get(CommentProviders), context.container.get("NotebookProviders"), context.container.get(DbtPowerUserMcpServer), + context.container.get(WhatsNewPanel), ); }) .inSingletonScope(); diff --git a/src/telemetry/events.ts b/src/telemetry/events.ts index f88b5c679..e899e79a2 100644 --- a/src/telemetry/events.ts +++ b/src/telemetry/events.ts @@ -77,4 +77,7 @@ export enum TelemetryEvents { "AltimateCode/RunHistoryFixClick" = "AltimateCode/RunHistoryFixClick", "AltimateCode/TroubleshootCodeActionClick" = "AltimateCode/TroubleshootCodeActionClick", "DocumentationEditor/UnitTestGenerateClick" = "DocumentationEditor/UnitTestGenerateClick", + "WhatsNew/PageOpened" = "whats_new_page_opened", + "WhatsNew/ChangelogItemClicked" = "changelog_item_clicked", + "WhatsNew/FullChangelogClicked" = "full_changelog_clicked", } diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts new file mode 100644 index 000000000..116b7aaa0 --- /dev/null +++ b/src/webview_provider/whatsNewPanel.ts @@ -0,0 +1,242 @@ +import { DBTTerminal } from "@altimateai/dbt-integration"; +import { inject } from "inversify"; +import fetch from "node-fetch"; +import { ViewColumn, WebviewPanel, window, workspace } from "vscode"; +import { AltimateRequest } from "../altimate"; +import { DBTProjectContainer } from "../dbt_client/dbtProjectContainer"; +import { AltimateAuthService } from "../services/altimateAuthService"; +import { AltimateCodeChatService } from "../services/altimateCodeChatService"; +import { QueryManifestService } from "../services/queryManifestService"; +import { SharedStateService } from "../services/sharedStateService"; +import { UsersService } from "../services/usersService"; +import { TelemetryService } from "../telemetry"; +import { TelemetryEvents } from "../telemetry/events"; +import { + AltimateWebviewProvider, + HandleCommandProps, +} from "./altimateWebviewProvider"; + +// A single curated changelog file is published by the Comm Center as an +// IDE-specific manifest, filtered to dbt-power-user entries. The webview +// renders directly from this shape; entry links resolve to `base_url + anchor` +// on the website changelog. +interface WhatsNewItem { + title: string; + tag: "new" | "improved" | "fix"; + summary: string; + anchor: string; +} + +interface WhatsNewManifest { + product: string; + version: string; + generated_at: string; + base_url: string; + items: WhatsNewItem[]; +} + +// TODO: point at the Comm Center IDE-manifest endpoint once its URL and keying +// (per-version file vs single feed) are finalized. `{version}` is replaced with +// the running extension version. Until then, `dbt.whatsNewManifestUrl` can be +// set to a reachable manifest for local/staging verification. +const WHATS_NEW_MANIFEST_URL = + "https://www.altimate.ai/changelog/ide-manifest/dbt-power-user/{version}.json"; + +// globalState keys +const LAST_SEEN_VERSION_KEY = "whatsNew.lastSeenVersion"; +const MANIFEST_CACHE_KEY = "whatsNew.cachedManifest"; + +type WhatsNewTrigger = "auto" | "manual"; + +export class WhatsNewPanel extends AltimateWebviewProvider { + public static readonly viewType = "dbtPowerUser.WhatsNew"; + protected viewPath = "/whats-new"; + protected panelDescription = "What's New in dbt Power User"; + private currentTrigger: WhatsNewTrigger = "manual"; + + public constructor( + protected dbtProjectContainer: DBTProjectContainer, + protected altimateRequest: AltimateRequest, + protected telemetry: TelemetryService, + protected emitterService: SharedStateService, + @inject("DBTTerminal") + protected dbtTerminal: DBTTerminal, + protected queryManifestService: QueryManifestService, + protected usersService: UsersService, + protected altimateAuthService: AltimateAuthService, + altimateCodeChatService: AltimateCodeChatService, + ) { + super( + dbtProjectContainer, + altimateRequest, + telemetry, + emitterService, + dbtTerminal, + queryManifestService, + usersService, + altimateAuthService, + altimateCodeChatService, + ); + } + + /** + * Opens (or focuses) the What's New panel in the editor area. Mirrors VS + * Code's own Release Notes: a one-off webview in the center, not a sidebar. + */ + public show(trigger: WhatsNewTrigger): void { + this.currentTrigger = trigger; + + if (this._panel) { + (this._panel as WebviewPanel).reveal?.(); + return; + } + + const webviewPanel = window.createWebviewPanel( + WhatsNewPanel.viewType, + "What's New · Power user for dbt", + { viewColumn: ViewColumn.Active }, + { enableScripts: true, retainContextWhenHidden: true }, + ); + this._panel = webviewPanel; + + webviewPanel.onDidDispose(() => { + this._panel = undefined; + this._webview = undefined; + this.isWebviewReady = false; + }); + + this._webview = webviewPanel.webview; + this.renderWebviewView(); + } + + protected renderWebviewView(): void { + if (!this._webview) { + return; + } + this._webview.onDidReceiveMessage(this.handleCommand, this, []); + this._webview.html = this.getHtml( + this._webview, + this.dbtProjectContainer.extensionUri, + ); + } + + /** + * Auto-open decision, run once on activation (mirrors initializeWalkthrough). + * Opens only on a minor/major version bump; patch releases stay silent. + * The current version is always persisted so users are not re-prompted. + */ + public checkAndShowOnActivation(): void { + const current = this.dbtProjectContainer.extensionVersion; + const lastSeen = this.dbtProjectContainer.getFromGlobalState( + LAST_SEEN_VERSION_KEY, + ) as string | undefined; + + this.dbtProjectContainer.setToGlobalState(LAST_SEEN_VERSION_KEY, current); + + const enabled = workspace + .getConfiguration("dbt") + .get("showChangelogOnUpdate", true); + if (!enabled) { + return; + } + + // Fresh install: defer to the setup walkthrough rather than the changelog. + if (!lastSeen) { + return; + } + + if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) { + this.show("auto"); + } + } + + private static parseVersion( + version: string, + ): [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); + if (!match) { + return undefined; + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; + } + + static isMinorOrMajorUpgrade(prev: string, curr: string): boolean { + const p = WhatsNewPanel.parseVersion(prev); + const c = WhatsNewPanel.parseVersion(curr); + if (!p || !c) { + return false; + } + if (c[0] !== p[0]) { + return c[0] > p[0]; + } + if (c[1] !== p[1]) { + return c[1] > p[1]; + } + return false; // same or patch-only change + } + + async handleCommand(message: HandleCommandProps): Promise { + const { command, syncRequestId } = message; + switch (command) { + case "getWhatsNewManifest": + this.handleSyncRequestFromWebview( + syncRequestId, + () => this.getManifestForWebview(), + command, + ); + break; + default: + super.handleCommand(message); + break; + } + } + + private async getManifestForWebview(): Promise { + const manifest = await this.fetchManifest(); + this.telemetry.sendTelemetryEvent( + TelemetryEvents["WhatsNew/PageOpened"], + { version: manifest.version, trigger: this.currentTrigger }, + { items_shown: manifest.items.length }, + ); + return manifest; + } + + private async fetchManifest(): Promise { + const version = this.dbtProjectContainer.extensionVersion; + const override = workspace + .getConfiguration("dbt") + .get("whatsNewManifestUrl", ""); + const url = (override || WHATS_NEW_MANIFEST_URL).replace( + "{version}", + version, + ); + + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`Manifest request failed with ${response.status}`); + } + const manifest = (await response.json()) as WhatsNewManifest; + this.dbtProjectContainer.setToGlobalState(MANIFEST_CACHE_KEY, manifest); + return manifest; + } catch (error) { + // Offline / endpoint down: fall back to the last successfully fetched + // manifest so the panel still renders something useful. + const cached = this.dbtProjectContainer.getFromGlobalState( + MANIFEST_CACHE_KEY, + ) as WhatsNewManifest | undefined; + if (cached) { + this.dbtTerminal.warn( + "whatsNew:fetchManifest", + `Failed to fetch What's New manifest; serving cached copy: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return cached; + } + throw error; + } + } +} diff --git a/webview_panels/package.json b/webview_panels/package.json index ad585365a..ae94c138a 100644 --- a/webview_panels/package.json +++ b/webview_panels/package.json @@ -18,7 +18,7 @@ "build-storybook": "storybook build" }, "dependencies": { - "@altimateai/ui-components": "0.0.86", + "@altimateai/ui-components": "0.0.87", "@ant-design/pro-chat": "^1.15.2", "@finos/perspective": "^2.10.0", "@finos/perspective-viewer": "^2.10.0", diff --git a/webview_panels/src/AppConstants.tsx b/webview_panels/src/AppConstants.tsx index f92c6379e..9fd254607 100644 --- a/webview_panels/src/AppConstants.tsx +++ b/webview_panels/src/AppConstants.tsx @@ -4,6 +4,7 @@ import DocumentationProvider from "@modules/documentationEditor/DocumentationPro import LineageView from "@modules/lineage/LineageView"; import Onboarding from "@modules/onboarding/Onboarding"; import QueryPanelProvider from "@modules/queryPanel/QueryPanelProvider"; +import WhatsNew from "@modules/whatsNew/WhatsNew"; import Home from "./modules/home/Home"; import Insights from "./modules/insights/Insights"; @@ -27,4 +28,5 @@ export const AvailableRoutes = { "/query-panel": { component: }, "/lineage": { component: }, "/onboarding": { component: }, + "/whats-new": { component: }, }; diff --git a/webview_panels/src/modules/whatsNew/WhatsNew.tsx b/webview_panels/src/modules/whatsNew/WhatsNew.tsx new file mode 100644 index 000000000..2d9408ed9 --- /dev/null +++ b/webview_panels/src/modules/whatsNew/WhatsNew.tsx @@ -0,0 +1,219 @@ +import { + executeRequestInAsync, + executeRequestInSync, +} from "@modules/app/requestExecutor"; +import { panelLogger } from "@modules/logger"; +import { TelemetryEvents } from "@telemetryEvents"; +import { Spinner } from "@altimateai/ui-components/lego"; +import { useEffect, useMemo, useState } from "react"; +import "@altimateai/ui-components/styles.css"; +import classes from "./whatsNew.module.scss"; + +interface WhatsNewItem { + title: string; + tag: "new" | "improved" | "fix"; + summary: string; + anchor: string; +} + +interface WhatsNewManifest { + product: string; + version: string; + generated_at: string; + base_url: string; + items: WhatsNewItem[]; +} + +// Fixed product-discovery links — Power user for dbt stays the active editor; +// clicking opens the filtered website changelog in the browser. +const PRODUCT_LINKS = [ + { label: "Altimate Code", url: "https://www.altimate.ai/changelog#altimate-code" }, + { label: "Datamates", url: "https://www.altimate.ai/changelog#datamates" }, + { label: "Snowflake", url: "https://www.altimate.ai/changelog#snowflake" }, + { label: "Databricks", url: "https://www.altimate.ai/changelog#databricks" }, +]; + +const TAG_ORDER: WhatsNewItem["tag"][] = ["new", "improved", "fix"]; +const SECTION_LABEL: Record = { + new: "New", + improved: "Improved", + fix: "Fixed", +}; +const PILL_LABEL: Record = { + new: "New", + improved: "Improved", + fix: "Fix", +}; + +const openUrl = (url: string): void => { + executeRequestInAsync("openURL", { url }); +}; + +const track = ( + eventName: string, + properties: Record, +): void => { + executeRequestInAsync("sendTelemetryEvent", { eventName, properties }); +}; + +const WhatsNew = (): JSX.Element => { + const [manifest, setManifest] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + executeRequestInSync("getWhatsNewManifest", {}) + .then((response) => setManifest(response as WhatsNewManifest)) + .catch((err) => { + panelLogger.error("failed to load What's New manifest", err); + setError(true); + }); + }, []); + + // Group items by tag, preserving manifest order (newest first) within a group. + const groups = useMemo(() => { + if (!manifest) { + return []; + } + return TAG_ORDER.map((tag) => ({ + tag, + items: manifest.items.filter((item) => item.tag === tag), + })).filter((group) => group.items.length > 0); + }, [manifest]); + + if (error) { + return ( + + ); + } + + if (!manifest) { + return ( +
+ +
+ ); + } + + const onFullChangelog = (): void => { + track(TelemetryEvents["WhatsNew/FullChangelogClicked"], { + version: manifest.version, + }); + openUrl(manifest.base_url); + }; + + const onItemClick = (item: WhatsNewItem): void => { + track(TelemetryEvents["WhatsNew/ChangelogItemClicked"], { + anchor: item.anchor, + version: manifest.version, + tag: item.tag, + }); + openUrl(manifest.base_url + item.anchor); + }; + + return ( +
+
+
+

In this update

+
+ Power user for dbt + · + {manifest.version} +
+ { + e.preventDefault(); + onFullChangelog(); + }} + > + See full changelog → + +
+ + +
+ +
+ +
+ + +
+ {groups.map((group) => ( +
+

+ {SECTION_LABEL[group.tag]} +

+ {group.items.map((item) => ( + + ))} +
+ ))} +
+
+
+ ); +}; + +export default WhatsNew; diff --git a/webview_panels/src/modules/whatsNew/sample-manifest.json b/webview_panels/src/modules/whatsNew/sample-manifest.json new file mode 100644 index 000000000..07b8ca0f9 --- /dev/null +++ b/webview_panels/src/modules/whatsNew/sample-manifest.json @@ -0,0 +1,50 @@ +{ + "product": "dbt-power-user", + "version": "1.43.0", + "generated_at": "2026-04-30T00:00:00Z", + "base_url": "https://www.altimate.ai/changelog", + "items": [ + { + "title": "Altimate Code chat lives inside Power user for dbt", + "tag": "new", + "summary": "Execute, Explain, Optimize, Document, and Review on any SQL or YAML file.", + "anchor": "#1.43.0-altimate-code-chat-lives-inside-power-user-for-dbt" + }, + { + "title": "RisingWave adapter support during onboarding", + "tag": "new", + "summary": "Installed and configured as a Postgres-compatible dialect — no manual setup.", + "anchor": "#1.43.0-risingwave-adapter-support-during-onboarding" + }, + { + "title": "CTE Profiler — per-CTE timing and row counts in the editor", + "tag": "new", + "summary": "Per-CTE time and row counts, decorated inline. No Altimate API key required.", + "anchor": "#1.43.0-cte-profiler-per-cte-timing-and-row-counts" + }, + { + "title": "Generate dbt unit tests from your manifest", + "tag": "new", + "summary": "Type-correct mock data — happy path, nulls, and boundaries — written into the model's schema.", + "anchor": "#1.43.0-generate-dbt-unit-tests-from-your-manifest" + }, + { + "title": "Model hovers and Run/Test codelenses in schema files", + "tag": "improved", + "summary": "Columns, types, and Run / Test / Document inline in schema YAML files.", + "anchor": "#1.43.0-model-hovers-and-run-test-codelenses" + }, + { + "title": "dbt-aware syntax highlighting for SQL+Jinja and YAML+Jinja", + "tag": "improved", + "summary": "Jinja blocks, refs, and sources highlighted distinctly from surrounding SQL and YAML.", + "anchor": "#1.43.0-dbt-aware-syntax-highlighting" + }, + { + "title": "Compiled-SQL preview no longer flickers on save", + "tag": "fix", + "summary": "The preview pane holds its scroll position and content while a model recompiles.", + "anchor": "#1.43.0-compiled-sql-preview-flicker" + } + ] +} diff --git a/webview_panels/src/modules/whatsNew/whatsNew.module.scss b/webview_panels/src/modules/whatsNew/whatsNew.module.scss new file mode 100644 index 000000000..f2c4ed7b9 --- /dev/null +++ b/webview_panels/src/modules/whatsNew/whatsNew.module.scss @@ -0,0 +1,213 @@ +.page { + --wn-accent: #4ec9b0; + + max-width: 1080px; + margin: 0 auto; + padding: 2.5rem 3rem 4rem; + color: var(--vscode-foreground); + font-size: 14px; + line-height: 1.5; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 3rem; +} + +.headerMain { + display: flex; + flex-direction: column; +} + +.title { + margin: 0; + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.version { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-top: 0.75rem; + color: var(--vscode-descriptionForeground); +} + +.product { + color: var(--wn-accent); + font-weight: 600; +} + +.dot { + opacity: 0.6; +} + +.fullChangelog { + margin-top: 1rem; + color: var(--vscode-textLink-foreground); + text-decoration: none; + width: fit-content; + + &:hover { + text-decoration: underline; + } +} + +.alsoFrom { + display: flex; + flex-direction: column; + gap: 0.6rem; + min-width: 190px; + padding-left: 1.75rem; + border-left: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.25)); +} + +.alsoFromLabel { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 600; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} + +.productLink { + color: var(--vscode-textLink-foreground); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.extArrow { + font-size: 0.8em; + opacity: 0.6; +} + +.divider { + margin: 2.25rem 0; + border: none; + border-top: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.2)); +} + +.body { + display: grid; + grid-template-columns: 160px 1fr; + gap: 3rem; +} + +.contents { + position: sticky; + top: 1.5rem; + align-self: start; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.contentsLabel { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 600; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + margin-bottom: 0.5rem; +} + +.contentsLink { + color: var(--vscode-descriptionForeground); + text-decoration: none; + padding: 0.3rem 0 0.3rem 0.75rem; + border-left: 2px solid transparent; + + &:hover { + color: var(--vscode-foreground); + border-left-color: var(--wn-accent); + } +} + +.entries { + display: flex; + flex-direction: column; +} + +.section { + margin-bottom: 2rem; +} + +.sectionTitle { + margin: 0 0 0.5rem; + padding-bottom: 0.5rem; + font-size: 1.35rem; + font-weight: 600; + border-bottom: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.15)); +} + +.entry { + margin: 1.25rem 0; +} + +.entryTitle { + display: inline-block; + color: var(--vscode-textLink-foreground); + text-decoration: none; + font-size: 1.05rem; + font-weight: 500; + + &:hover { + text-decoration: underline; + } +} + +.entrySummary { + margin: 0.35rem 0 0; + color: var(--vscode-descriptionForeground); +} + +.stateWrap { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + min-height: 60vh; + color: var(--vscode-descriptionForeground); + + a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } +} + +@media (max-width: 720px) { + .page { + padding: 1.5rem; + } + + .header { + flex-direction: column; + gap: 1.5rem; + } + + .alsoFrom { + padding-left: 0; + border-left: none; + } + + .body { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .contents { + position: static; + flex-direction: row; + flex-wrap: wrap; + gap: 0.75rem; + } +}