@hienlh/ppm 0.17.33 → 0.17.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +1 -0
- package/Dockerfile.test +5 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +5 -0
- package/dist/web/assets/{ai-resource-editor-CZc98en9.js → ai-resource-editor-aT2nC1lK.js} +1 -1
- package/dist/web/assets/{audio-preview-XkBaDV-d.js → audio-preview-DPS0VpgU.js} +1 -1
- package/dist/web/assets/chat-tab-k7e8hE9y.js +13 -0
- package/dist/web/assets/{code-editor-DeqsPj8M.js → code-editor-C8ClI1kO.js} +2 -2
- package/dist/web/assets/{conflict-editor-CqORnyRu.js → conflict-editor-Dag7buRT.js} +1 -1
- package/dist/web/assets/{csv-preview-i79R-2TA.js → csv-preview-6JQDYpOU.js} +1 -1
- package/dist/web/assets/{database-viewer-ClrtBc_A.js → database-viewer-BFzNauDX.js} +1 -1
- package/dist/web/assets/{diff-viewer-CWUjtM3T.js → diff-viewer-Dub9yryk.js} +1 -1
- package/dist/web/assets/{docx-preview-CFjtBdLf.js → docx-preview-Bj2Xzdef.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
- package/dist/web/assets/{extension-webview-DwRdXtK4.js → extension-webview-DUqZPzfp.js} +1 -1
- package/dist/web/assets/{git-log-panel-DI_uaWVT.js → git-log-panel-90_p5Qdn.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Q2kMqNeo.js → glide-data-grid-BSSyHBRx.js} +1 -1
- package/dist/web/assets/group-chat-tab-j1CoraSo.js +1 -0
- package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
- package/dist/web/assets/{image-preview-QyeYNiJt.js → image-preview-ChhxwmZU.js} +1 -1
- package/dist/web/assets/index-BA8NcuLe.js +50 -0
- package/dist/web/assets/index-eW0VEaRF.css +2 -0
- package/dist/web/assets/{keybindings-store-55cr_nFT.js → keybindings-store-Bp-oUCTT.js} +1 -1
- package/dist/web/assets/markdown-renderer-BaPAXsZW.js +3 -0
- package/dist/web/assets/markdown-renderer-CUHI3gc6.js +1 -0
- package/dist/web/assets/{notification-store-Bwzk7bz9.js → notification-store-COr3urzj.js} +1 -1
- package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
- package/dist/web/assets/{pdf-preview-Dd65pYcK.js → pdf-preview-cJC8Cm9R.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BAeRjowr.js → postgres-viewer-uMVBwY8R.js} +1 -1
- package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-CBLPPIts.js} +2 -2
- package/dist/web/assets/{settings-tab-D_Vz5vjU.js → settings-tab-BdEa4MLs.js} +1 -1
- package/dist/web/assets/{sql-query-editor-RrxjEhFI.js → sql-query-editor-R1WsE3n7.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DULpl_6h.js → sqlite-viewer-CppHnW_J.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-DHOIu_5Y.js → system-monitor-tab-BzK46Q3D.js} +1 -1
- package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
- package/dist/web/assets/{terminal-tab-B64jdELv.js → terminal-tab-2CWIYa05.js} +1 -1
- package/dist/web/assets/tool-cards-DS4vQ3qy.js +7 -0
- package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
- package/dist/web/assets/{use-monaco-theme-Bf_8qog4.js → use-monaco-theme-CEAY5h6Y.js} +1 -1
- package/dist/web/assets/{video-preview-C-ZB6w-Z.js → video-preview-C7RTSYae.js} +1 -1
- package/dist/web/index.html +5 -5
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +5 -1
- package/package.json +3 -2
- package/src/providers/claude-agent-sdk.ts +4 -3
- package/src/server/routes/chat.ts +66 -1
- package/src/server/routes/fs-browse.ts +13 -2
- package/src/services/image-dimensions.ts +67 -0
- package/src/services/jsonl-transcript-parser.ts +2 -1
- package/src/services/transcript-images.ts +174 -0
- package/src/shared/image-extensions.ts +19 -0
- package/src/shared/tool-result-content.ts +81 -0
- package/src/web/components/chat/chat-history-bar.tsx +3 -104
- package/src/web/components/chat/image-preview-geometry.ts +83 -0
- package/src/web/components/chat/image-preview-states.tsx +51 -0
- package/src/web/components/chat/message-list.tsx +10 -3
- package/src/web/components/chat/session-debug-dialog.tsx +240 -0
- package/src/web/components/chat/tool-cards.tsx +27 -2
- package/src/web/components/chat/tool-image-preview.tsx +227 -0
- package/src/web/components/editor/audio-preview.tsx +1 -1
- package/src/web/components/editor/image-preview.tsx +1 -1
- package/src/web/components/editor/video-preview.tsx +1 -1
- package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
- package/src/web/components/layout/editor-panel.tsx +31 -5
- package/src/web/components/layout/notification-bell-popover.tsx +5 -1
- package/src/web/components/layout/tab-bar.tsx +2 -2
- package/src/web/components/shared/image-overlay.tsx +188 -24
- package/src/web/components/shared/markdown-context.ts +1 -1
- package/src/web/components/shared/markdown-renderer.tsx +3 -1
- package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
- package/src/web/hooks/use-image-transform.ts +276 -0
- package/src/web/lib/clipboard.ts +41 -0
- package/src/web/lib/file-download.ts +1 -1
- package/src/web/lib/image-gallery.ts +25 -0
- package/src/web/stores/image-overlay-store.ts +35 -7
- package/src/web/stores/panel-store.ts +40 -6
- package/src/web/stores/panel-utils.ts +19 -0
- package/src/web/styles/globals.css +72 -0
- package/dist/web/assets/chat-tab-Cpxrbm1O.js +0 -13
- package/dist/web/assets/group-chat-tab-2NJcUl_4.js +0 -1
- package/dist/web/assets/index-BI35sAVB.js +0 -50
- package/dist/web/assets/index-BW2cOdzp.css +0 -2
- package/dist/web/assets/markdown-renderer-ClDzjZ5T.js +0 -1
- package/dist/web/assets/markdown-renderer-DoofKFOC.js +0 -3
- package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
- package/dist/web/assets/tool-cards-DT3R2C18.js +0 -7
- package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
- /package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-BLUwA0Gh.js} +0 -0
- /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-DVHBcvuE.js} +0 -0
- /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
- /package/dist/web/assets/{libesm-Dd8zbj1v.js → libesm-DdtEjhIi.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GalleryImage } from "@/stores/image-overlay-store";
|
|
2
|
+
|
|
3
|
+
/** Marks a container whose tagged images form one navigable set. */
|
|
4
|
+
export const GALLERY_ROOT_ATTR = "data-image-gallery";
|
|
5
|
+
/** Marks an `<img>` that belongs to the enclosing gallery. */
|
|
6
|
+
export const GALLERY_ITEM_ATTR = "data-gallery-item";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collect the images a viewer may move between, in the order they appear on screen.
|
|
10
|
+
*
|
|
11
|
+
* Read from the DOM rather than a registry because every image already owns a blob URL that
|
|
12
|
+
* is revoked when its component unmounts — so the mounted elements are exactly the set whose
|
|
13
|
+
* sources are still valid, and the DOM already holds them in the order the user sees.
|
|
14
|
+
*
|
|
15
|
+
* The reach is therefore whatever is mounted. The chat transcript is deliberately not
|
|
16
|
+
* virtualised (`message-list.tsx`), so that is the whole conversation today; if it ever gains
|
|
17
|
+
* data windowing, navigation narrows to the loaded window along with everything else.
|
|
18
|
+
*/
|
|
19
|
+
export function collectGallery(from: Element | null): GalleryImage[] {
|
|
20
|
+
const root = from?.closest(`[${GALLERY_ROOT_ATTR}]`);
|
|
21
|
+
if (!root) return [];
|
|
22
|
+
return [...root.querySelectorAll<HTMLImageElement>(`img[${GALLERY_ITEM_ATTR}]`)]
|
|
23
|
+
.filter((img) => !!img.src)
|
|
24
|
+
.map((img) => ({ src: img.src, alt: img.alt }));
|
|
25
|
+
}
|
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
|
|
3
|
+
export interface GalleryImage {
|
|
4
|
+
/** Blob URL or data URL of the image. */
|
|
5
|
+
src: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
interface ImageOverlayState {
|
|
4
|
-
/**
|
|
10
|
+
/** Images the overlay can move between; a single entry when opened without a gallery. */
|
|
11
|
+
images: GalleryImage[];
|
|
12
|
+
index: number;
|
|
13
|
+
/** Convenience mirrors of the current entry — every existing consumer reads these. */
|
|
5
14
|
src: string | null;
|
|
6
15
|
alt: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Open the overlay. Passing a gallery lets the arrows walk its siblings; the clicked
|
|
18
|
+
* source decides where in that list the viewer starts.
|
|
19
|
+
*/
|
|
20
|
+
open: (src: string, alt?: string, gallery?: GalleryImage[]) => void;
|
|
10
21
|
close: () => void;
|
|
22
|
+
/** Move by a signed offset, stopping at the ends rather than wrapping. */
|
|
23
|
+
go: (delta: number) => void;
|
|
11
24
|
}
|
|
12
25
|
|
|
13
|
-
export const useImageOverlay = create<ImageOverlayState>((set) => ({
|
|
26
|
+
export const useImageOverlay = create<ImageOverlayState>((set, get) => ({
|
|
27
|
+
images: [],
|
|
28
|
+
index: 0,
|
|
14
29
|
src: null,
|
|
15
30
|
alt: "",
|
|
16
|
-
open: (src, alt = "") =>
|
|
17
|
-
|
|
31
|
+
open: (src, alt = "", gallery) => {
|
|
32
|
+
const images = gallery?.length ? gallery : [{ src, alt }];
|
|
33
|
+
const found = images.findIndex((i) => i.src === src);
|
|
34
|
+
const index = found >= 0 ? found : 0;
|
|
35
|
+
const current = images[index]!;
|
|
36
|
+
set({ images, index, src: current.src, alt: current.alt || alt });
|
|
37
|
+
},
|
|
38
|
+
close: () => set({ images: [], index: 0, src: null, alt: "" }),
|
|
39
|
+
go: (delta) => {
|
|
40
|
+
const { images, index } = get();
|
|
41
|
+
const next = index + delta;
|
|
42
|
+
if (next < 0 || next >= images.length) return;
|
|
43
|
+
const current = images[next]!;
|
|
44
|
+
set({ index: next, src: current.src, alt: current.alt });
|
|
45
|
+
},
|
|
18
46
|
}));
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
savePanelLayout,
|
|
16
16
|
loadPanelLayout,
|
|
17
17
|
deriveTabId,
|
|
18
|
+
visibleTabs,
|
|
18
19
|
DOCK_PANEL_ID,
|
|
19
20
|
} from "./panel-utils";
|
|
20
21
|
import {
|
|
@@ -248,10 +249,16 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
248
249
|
// Load from localStorage
|
|
249
250
|
const loaded = loadPanelLayout(projectName);
|
|
250
251
|
if (loaded && Object.keys(loaded.panels).length > 0) {
|
|
251
|
-
// Migrate:
|
|
252
|
+
// Migrate: drop obsolete tab types, and tabs belonging to another project.
|
|
253
|
+
// A foreign tab is filtered out of the tab bar, so it can never be closed —
|
|
254
|
+
// and closeTab is the only thing that removes an emptied panel, so leaving
|
|
255
|
+
// one behind wedges its panel permanently (it renders as a blank slot).
|
|
256
|
+
let healed = false;
|
|
252
257
|
const migratedPanels: typeof loaded.panels = {};
|
|
253
258
|
for (const [pid, panel] of Object.entries(loaded.panels)) {
|
|
254
|
-
const filteredTabs = panel.tabs
|
|
259
|
+
const filteredTabs = visibleTabs(panel.tabs, projectName)
|
|
260
|
+
.filter((t) => !OBSOLETE_TAB_TYPES.has(t.type));
|
|
261
|
+
if (filteredTabs.length !== panel.tabs.length) healed = true;
|
|
255
262
|
const filteredHistory = panel.tabHistory.filter(
|
|
256
263
|
(id) => filteredTabs.some((t) => t.id === id),
|
|
257
264
|
);
|
|
@@ -261,6 +268,22 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
261
268
|
migratedPanels[pid] = { ...panel, tabs: filteredTabs, tabHistory: filteredHistory, activeTabId };
|
|
262
269
|
}
|
|
263
270
|
|
|
271
|
+
// Apply the "no empty panel" invariant on restore, not just in closeTab:
|
|
272
|
+
// the filtering above can empty a panel, and a grid id with no panel behind
|
|
273
|
+
// it renders nothing either. The last panel always stays — the grid needs
|
|
274
|
+
// something to render into.
|
|
275
|
+
let migratedGrid = loaded.grid;
|
|
276
|
+
for (const pid of migratedGrid.flat()) {
|
|
277
|
+
if ((migratedPanels[pid]?.tabs.length ?? 0) > 0) continue;
|
|
278
|
+
if (migratedGrid.flat().length <= 1) break;
|
|
279
|
+
migratedGrid = gridRemovePanel(migratedGrid, pid);
|
|
280
|
+
delete migratedPanels[pid];
|
|
281
|
+
healed = true;
|
|
282
|
+
}
|
|
283
|
+
const migratedFocused = migratedGrid.flat().includes(loaded.focusedPanelId)
|
|
284
|
+
? loaded.focusedPanelId
|
|
285
|
+
: (migratedGrid.flat()[0] ?? loaded.focusedPanelId);
|
|
286
|
+
|
|
264
287
|
// Merge into flat panels map (keep-alive: old panels stay).
|
|
265
288
|
// The shared __dock__ panel holds live tabs from ALL projects — union the
|
|
266
289
|
// loaded project's persisted dock tabs into it rather than replacing, so a
|
|
@@ -283,8 +306,8 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
283
306
|
// Focus the switched-to project's own dock tab (not another project's).
|
|
284
307
|
mergedDockPanel.activeTabId = pickDockActiveTab(mergedDockPanel, projectName);
|
|
285
308
|
const mergedPanels = { ...panels, ...migratedPanels, [DOCK_PANEL_ID]: mergedDockPanel };
|
|
286
|
-
newProjectGrids[projectName] =
|
|
287
|
-
newProjectFocused[projectName] =
|
|
309
|
+
newProjectGrids[projectName] = migratedGrid;
|
|
310
|
+
newProjectFocused[projectName] = migratedFocused;
|
|
288
311
|
const restoredDock = collapseRestoredDockOnMobile(
|
|
289
312
|
loaded.dock ?? restoreDockForProject(projectName, newProjectDock),
|
|
290
313
|
get().isMobile(),
|
|
@@ -292,13 +315,24 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
292
315
|
set({
|
|
293
316
|
currentProject: projectName,
|
|
294
317
|
panels: mergedPanels,
|
|
295
|
-
grid:
|
|
296
|
-
focusedPanelId:
|
|
318
|
+
grid: migratedGrid,
|
|
319
|
+
focusedPanelId: migratedFocused,
|
|
297
320
|
projectGrids: newProjectGrids,
|
|
298
321
|
projectFocused: newProjectFocused,
|
|
299
322
|
dock: restoredDock,
|
|
300
323
|
projectDock: newProjectDock,
|
|
301
324
|
});
|
|
325
|
+
// Write the healed layout back so the repair sticks (and reaches the server
|
|
326
|
+
// blob), instead of being redone on every load.
|
|
327
|
+
if (healed) {
|
|
328
|
+
savePanelLayout(projectName, {
|
|
329
|
+
panels: migratedPanels,
|
|
330
|
+
grid: migratedGrid,
|
|
331
|
+
focusedPanelId: migratedFocused,
|
|
332
|
+
dock: restoredDock,
|
|
333
|
+
dockPanel: mergedDockPanel,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
302
336
|
} else {
|
|
303
337
|
// Create empty layout — EmptyPanel will show quick-open buttons
|
|
304
338
|
const p = createPanel();
|
|
@@ -103,6 +103,25 @@ export function gridRemovePanel(grid: string[][], panelId: string): string[][] {
|
|
|
103
103
|
.filter((col) => col.length > 0);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The subset of `tabs` that the given project is allowed to see.
|
|
108
|
+
*
|
|
109
|
+
* A grid panel can end up holding another project's tab (a race in openTab during
|
|
110
|
+
* a project switch, or a tab stamped with a project name that does not exist). The
|
|
111
|
+
* tab bar hides those, so every other "is this panel empty?" check must agree —
|
|
112
|
+
* otherwise the panel renders neither tabs nor the empty state, and since it has no
|
|
113
|
+
* closable tab, the auto-close in closeTab can never fire and the panel is stuck.
|
|
114
|
+
*
|
|
115
|
+
* Callers spell "no active project" two ways: TabBar passes null, while the
|
|
116
|
+
* per-project layout key is the "__global__" sentinel. Both mean unfiltered, so
|
|
117
|
+
* they are normalized here — a split between them would resurrect the exact
|
|
118
|
+
* disagreement this helper exists to prevent.
|
|
119
|
+
*/
|
|
120
|
+
export function visibleTabs(tabs: Tab[], projectName: string | null): Tab[] {
|
|
121
|
+
if (!projectName || projectName === "__global__") return tabs;
|
|
122
|
+
return tabs.filter((t) => !t.projectId || t.projectId === projectName);
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
export function findPanelPosition(grid: string[][], panelId: string): { row: number; col: number } | null {
|
|
107
126
|
for (let r = 0; r < grid.length; r++) {
|
|
108
127
|
const c = grid[r]!.indexOf(panelId);
|
|
@@ -101,6 +101,78 @@
|
|
|
101
101
|
--blur: 16px;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/*
|
|
105
|
+
* Ambient image stage (chat Read-tool image results). The panel floats a tile on a
|
|
106
|
+
* blurred copy of the image itself, so its surfaces are translucent veils rather than
|
|
107
|
+
* theme tokens. Declared per mode because the veil must stay contrast-safe over both a
|
|
108
|
+
* bright poster and a dark screenshot. Dark lives in :root, matching the default above.
|
|
109
|
+
*/
|
|
110
|
+
:root {
|
|
111
|
+
--img-veil: linear-gradient(100deg, rgba(10, 16, 32, 0.6), rgba(10, 16, 32, 0.84));
|
|
112
|
+
--img-backdrop: blur(40px) saturate(1.6);
|
|
113
|
+
--img-plate: rgba(255, 255, 255, 0.06);
|
|
114
|
+
--img-plate-border: rgba(148, 178, 255, 0.22);
|
|
115
|
+
--img-plate-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 16px 34px -20px rgba(0, 0, 0, 0.7);
|
|
116
|
+
--img-ghost: rgba(255, 255, 255, 0.08);
|
|
117
|
+
--img-ghost-border: rgba(255, 255, 255, 0.14);
|
|
118
|
+
--img-ghost-hover: rgba(255, 255, 255, 0.14);
|
|
119
|
+
--img-ghost-hover-border: rgba(148, 178, 255, 0.35);
|
|
120
|
+
--img-meta-label: #8b97b4;
|
|
121
|
+
--img-checker: rgba(255, 255, 255, 0.04);
|
|
122
|
+
/* Own values rather than a surface token: on flat light themes panel and panel-2 are
|
|
123
|
+
both #ffffff, which would make the skeleton invisible on the card. */
|
|
124
|
+
--img-skeleton: rgba(255, 255, 255, 0.09);
|
|
125
|
+
--img-skeleton-sweep: rgba(255, 255, 255, 0.14);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
html.light {
|
|
129
|
+
--img-veil: linear-gradient(100deg, rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.84));
|
|
130
|
+
--img-backdrop: blur(40px) saturate(1.6) brightness(0.95);
|
|
131
|
+
--img-plate: rgba(255, 255, 255, 0.5);
|
|
132
|
+
--img-plate-border: rgba(255, 255, 255, 0.7);
|
|
133
|
+
--img-plate-shadow: 0 2px 4px rgba(15, 23, 42, 0.14), 0 16px 34px -20px rgba(15, 23, 42, 0.55);
|
|
134
|
+
--img-ghost: rgba(255, 255, 255, 0.7);
|
|
135
|
+
--img-ghost-border: rgba(255, 255, 255, 0.85);
|
|
136
|
+
--img-ghost-hover: rgba(255, 255, 255, 0.95);
|
|
137
|
+
--img-ghost-hover-border: #cbd5e1;
|
|
138
|
+
--img-meta-label: #7c8899;
|
|
139
|
+
--img-checker: rgba(15, 23, 42, 0.03);
|
|
140
|
+
--img-skeleton: rgba(15, 23, 42, 0.07);
|
|
141
|
+
--img-skeleton-sweep: rgba(255, 255, 255, 0.75);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Backing for image tiles that letterbox: shows the transparent area without reading
|
|
145
|
+
as an empty box. Padding keeps the artwork off the tile edge. */
|
|
146
|
+
.img-checkerboard {
|
|
147
|
+
background-image:
|
|
148
|
+
linear-gradient(45deg, var(--img-checker) 25%, transparent 25%, transparent 75%, var(--img-checker) 75%),
|
|
149
|
+
linear-gradient(45deg, var(--img-checker) 25%, transparent 25%, transparent 75%, var(--img-checker) 75%);
|
|
150
|
+
background-size: 14px 14px;
|
|
151
|
+
background-position: 0 0, 7px 7px;
|
|
152
|
+
padding: 6px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Skeleton that occupies the loaded box exactly, so the row never grows after paint. */
|
|
156
|
+
.img-skeleton {
|
|
157
|
+
position: relative;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
background: var(--img-skeleton);
|
|
160
|
+
}
|
|
161
|
+
.img-skeleton::after {
|
|
162
|
+
content: "";
|
|
163
|
+
position: absolute;
|
|
164
|
+
inset: 0;
|
|
165
|
+
background: linear-gradient(100deg, transparent 20%, var(--img-skeleton-sweep) 50%, transparent 80%);
|
|
166
|
+
animation: img-skeleton-sweep 1.25s linear infinite;
|
|
167
|
+
}
|
|
168
|
+
@keyframes img-skeleton-sweep {
|
|
169
|
+
from { transform: translateX(-100%); }
|
|
170
|
+
to { transform: translateX(100%); }
|
|
171
|
+
}
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.img-skeleton::after { animation: none; }
|
|
174
|
+
}
|
|
175
|
+
|
|
104
176
|
/* Smooth theme transitions (~250ms) */
|
|
105
177
|
html {
|
|
106
178
|
transition: background-color 0.25s ease-out, color 0.25s ease-out;
|