@open-press/core 1.2.1 → 1.3.2

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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/engine/commands/typecheck.mjs +1 -1
  3. package/engine/document-export.mjs +1 -1
  4. package/engine/output/page-block.mjs +11 -2
  5. package/engine/output/public-assets.mjs +41 -6
  6. package/engine/output/static-server.mjs +68 -15
  7. package/engine/react/caption-numbering.mjs +2 -2
  8. package/engine/react/comment-marker.mjs +1 -2
  9. package/engine/react/document-entry.mjs +64 -11
  10. package/engine/react/document-export.d.mts +6 -0
  11. package/engine/react/document-export.mjs +158 -28
  12. package/engine/react/mdx-compile.mjs +4 -4
  13. package/engine/react/measurement-css.mjs +3 -3
  14. package/engine/react/page-folio.mjs +37 -0
  15. package/engine/react/pagination/allocator.mjs +4 -4
  16. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  17. package/engine/react/press-tree-inspection.mjs +43 -13
  18. package/engine/react/project-asset-endpoint.mjs +45 -11
  19. package/engine/react/sources/heading-numbering.mjs +2 -2
  20. package/engine/react/sources/mdx-resolver.mjs +3 -3
  21. package/engine/react/style-discovery.mjs +60 -11
  22. package/engine/react/text-source-transform.mjs +18 -4
  23. package/engine/runtime/config.mjs +22 -22
  24. package/engine/runtime/file-utils.mjs +57 -13
  25. package/engine/runtime/inspection.mjs +40 -15
  26. package/engine/runtime/page-geometry.mjs +6 -6
  27. package/engine/runtime/source-text-tools.mjs +28 -4
  28. package/engine/runtime/source-workspace.mjs +6 -9
  29. package/engine/runtime/validation.mjs +42 -24
  30. package/package.json +1 -1
  31. package/src/openpress/app/OpenPressApp.tsx +20 -18
  32. package/src/openpress/app/OpenPressRuntime.tsx +3 -3
  33. package/src/openpress/app/WorkspaceGalleryPage.tsx +65 -39
  34. package/src/openpress/core/PageFolio.tsx +115 -0
  35. package/src/openpress/core/Press.tsx +5 -10
  36. package/src/openpress/core/Slide.tsx +11 -0
  37. package/src/openpress/core/index.tsx +4 -0
  38. package/src/openpress/core/types.ts +21 -13
  39. package/src/openpress/core/useSource.ts +1 -1
  40. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  41. package/src/openpress/reader/SlidePresentationPage.tsx +7 -3
  42. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +46 -43
  43. package/src/styles/openpress/workbench-toolbar.css +33 -0
  44. package/src/styles/openpress/workspace-gallery.css +130 -47
  45. package/vite.config.ts +82 -16
@@ -14,17 +14,16 @@ export interface AllocationHints {
14
14
  totalPagesPerChain: Record<string, number>;
15
15
  }
16
16
 
17
- // Metadata read from <Press> props by the engine pipeline. The 1.0 contract
18
- // declares these on the component; v0.x reads them from openpress.config.mjs
19
- // instead and leaves these as undefined. The engine merges both sources
20
- // (props override config) until v1.0 removes config support.
17
+ // Metadata read from <Press> props by the engine pipeline. Each
18
+ // press/<slug>/press.tsx entry declares its document metadata here.
21
19
  export interface PressMetadata {
22
20
  title?: string;
23
21
  type?: PressProps["type"];
24
22
  page?: PressProps["page"];
25
23
  slug?: string;
26
24
  theme?: string;
27
- componentsDir?: string;
25
+ componentsDir?: PressProps["componentsDir"];
26
+ mediaDir?: PressProps["mediaDir"];
28
27
  }
29
28
 
30
29
  export interface PressContextValue {
@@ -36,9 +35,7 @@ export interface PressContextValue {
36
35
  // the first measurement pass.
37
36
  hints: AllocationHints | null;
38
37
  toc: Record<string, TocEntry[]> | null;
39
- // Metadata declared on <Press> props in v1.0. Engine providers may
40
- // omit this on v0.x; consumers should treat undefined as "no metadata
41
- // declared on Press — fall back to openpress.config.mjs values".
38
+ // Metadata declared on <Press> props.
42
39
  metadata?: PressMetadata;
43
40
  }
44
41
 
@@ -48,8 +45,6 @@ export function Press(props: PressProps) {
48
45
  // Press is intentionally inert at render time — the engine reads its
49
46
  // props and children through React.Children inspection during the
50
47
  // export pipeline, then injects context above any nested helpers.
51
- // For the v0.x shape (children-only usage), this still passes children
52
- // through unchanged.
53
48
  return <Fragment>{props.children}</Fragment>;
54
49
  }
55
50
 
@@ -0,0 +1,11 @@
1
+ import { Frame } from "./Frame";
2
+ import type { SlideProps } from "./types";
3
+
4
+ export function Slide({
5
+ id,
6
+ role = "canvas.slide",
7
+ chrome = false,
8
+ ...rest
9
+ }: SlideProps) {
10
+ return <Frame {...rest} frameKey={id} role={role} chrome={chrome} />;
11
+ }
@@ -8,13 +8,16 @@
8
8
  export { Press, PressContext, PRESS_MARKER } from "./Press";
9
9
  export { Workspace, WorkspaceContext, WORKSPACE_MARKER } from "./Workspace";
10
10
  export { Frame, FRAME_MARKER } from "./Frame";
11
+ export { Slide } from "./Slide";
11
12
  export { FrameContext } from "./FrameContext";
13
+ export { PageFolio } from "./PageFolio";
12
14
  export { MdxArea } from "./MdxArea";
13
15
  export { useSource } from "./useSource";
14
16
  export { ObjectEntity, Text, BaseFigure, BaseCallout, MediaFigure, ImageFigure } from "./primitives";
15
17
 
16
18
  export type {
17
19
  FrameProps,
20
+ SlideProps,
18
21
  FrameRole,
19
22
  MdxAreaProps,
20
23
  MdxAreaOverflow,
@@ -45,3 +48,4 @@ export type {
45
48
  export type { PressContextValue, AllocationHints, PressMetadata } from "./Press";
46
49
  export type { WorkspaceContextValue } from "./Workspace";
47
50
  export type { FrameContextValue } from "./FrameContext";
51
+ export type { PageFolioNumberFormat, PageFolioProps, PageFolioVariant } from "./PageFolio";
@@ -18,6 +18,12 @@ export type FrameProps = Omit<HTMLAttributes<HTMLElement>, "role" | "children">
18
18
  children?: ReactNode;
19
19
  };
20
20
 
21
+ export type SlideProps = Omit<FrameProps, "frameKey" | "role" | "chrome" | "title"> & {
22
+ id: string;
23
+ role?: FrameRole;
24
+ chrome?: boolean;
25
+ };
26
+
21
27
  export type MdxAreaOverflow = "extend" | "truncate" | "error";
22
28
 
23
29
  export type MdxAreaProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
@@ -47,9 +53,9 @@ export interface PressProps {
47
53
  // Document tree — Frames, manuscript helpers, etc.
48
54
  children: ReactNode;
49
55
  // -------------------------------------------------------------------------
50
- // 1.0 metadata props — optional during v0.x deprecation, required in v1.0.
56
+ // Press metadata props.
51
57
  // -------------------------------------------------------------------------
52
- // Document title. Required in 1.0. Used for PDF metadata, HTML <title>,
58
+ // Document title. Used for PDF metadata, HTML <title>,
53
59
  // OG tags, and the Workspace gallery / tab-bar label.
54
60
  title?: string;
55
61
  // Creation mode. Pages are source-driven with MDX allocation; slides are
@@ -61,15 +67,17 @@ export interface PressProps {
61
67
  // Array of source registrations from mdxSource(). Replaces the v0.x
62
68
  // `export const sources` named export.
63
69
  sources?: ReadonlyArray<PressSource>;
64
- // URL / output slug for this Press inside a Workspace. Defaults to
65
- // "/" when only one Press exists in the Workspace; required when the
66
- // Workspace has multiple Press children.
70
+ // URL / output slug for this Press. Must match the Press folder name.
67
71
  slug?: string;
68
- // Optional per-Press theme directory. Defaults to "./theme" relative
69
- // to the document file; inherits from <Workspace theme> if not set.
72
+ // Optional per-Press theme directory. Defaults include the folder-local
73
+ // "./theme"; shared CSS lives in press/shared/theme.
70
74
  theme?: string;
71
- // Optional per-Press components directory. Default "./components".
72
- componentsDir?: string;
75
+ // Optional per-Press components directories. Defaults include the
76
+ // folder-local "./components" and workspace shared components.
77
+ componentsDir?: string | string[];
78
+ // Optional per-Press media directories. Defaults include the folder-local
79
+ // "./media" and workspace shared media.
80
+ mediaDir?: string | string[];
73
81
  // Optional caption numbering overrides. Engine defaults to
74
82
  // { figure: "Figure", table: "Table", separator: " " }.
75
83
  captionNumbering?: {
@@ -80,7 +88,8 @@ export interface PressProps {
80
88
  }
81
89
 
82
90
  // ---------------------------------------------------------------------------
83
- // Workspace — root component holding one or more Press children
91
+ // Workspace — engine-owned grouping component holding one or more Press
92
+ // children from discovered press/*/press.tsx entries.
84
93
  // ---------------------------------------------------------------------------
85
94
 
86
95
  export interface WorkspaceProps {
@@ -90,10 +99,9 @@ export interface WorkspaceProps {
90
99
  // Project label surfaced in the gallery header, tab bar, and PDF
91
100
  // metadata. Optional.
92
101
  name?: string;
93
- // Workspace-level shared theme directory. Press children that don't
94
- // set their own `theme` prop inherit from this. Default "./theme".
102
+ // Reserved for future workspace-level shared theme overrides.
95
103
  theme?: string;
96
- // Workspace-level shared media directory. Default "./media".
104
+ // Reserved for future workspace-level shared media overrides.
97
105
  media?: string;
98
106
  }
99
107
 
@@ -21,7 +21,7 @@ export function useSource<T extends ResolvedSource = ResolvedSource>(id: string)
21
21
  const knownText = known.length > 0 ? known.join(", ") : "(none)";
22
22
  throw new Error(
23
23
  `Unknown source "${id}". Available sources: ${knownText}. ` +
24
- `Register it as a <Press sources={[mdxSource({ id: "${id}", ... })]}> entry in press/index.tsx.`,
24
+ `Register it as a <Press sources={[mdxSource({ id: "${id}", ... })]}> entry in press/<slug>/press.tsx.`,
25
25
  );
26
26
  }
27
27
  return source as T;
@@ -1,10 +1,6 @@
1
1
  // Shape of /openpress/workspace.json — the reader fetches this on
2
2
  // boot to decide between gallery routing (multi-Press) and direct
3
- // load (single Press). One entry per <Press> child of <Workspace>.
4
- //
5
- // Single-Press workspaces emit one entry with slug = "" and the
6
- // legacy /openpress/document.json path. Multi-Press emits one entry
7
- // per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
3
+ // load (single Press). One entry per discovered Press folder.
8
4
  import type { PressType } from "./documentTypes";
9
5
 
10
6
  export interface WorkspaceManifest {
@@ -16,13 +12,12 @@ export interface WorkspaceManifest {
16
12
  }
17
13
 
18
14
  export interface WorkspaceManifestPress {
19
- // Slug for this Press. Empty string for single-Press workspaces
20
- // (legacy root); slug-shaped string for multi-Press.
15
+ // Slug for this Press. Matches the folder-convention Press slug.
21
16
  slug: string;
22
17
  // <Press title="..."> prop. Required in v1.0 contract.
23
18
  title: string;
24
- // Creation mode declared by <Press type>. Defaults to "pages" for older
25
- // documents. The reader uses this for mode-specific navigation affordances.
19
+ // Creation mode declared by <Press type>. The reader uses this for
20
+ // mode-specific navigation affordances.
26
21
  type: PressType;
27
22
  // Page geometry summary. Same shape as the reader's
28
23
  // ReaderDocument.theme — readers can show a thumb in the gallery
@@ -29,9 +29,13 @@ export function SlidePresentationPage({
29
29
  if (typeof window === "undefined") return 0;
30
30
  return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0;
31
31
  });
32
- const [uiMode, setUiMode] = useState<PresentationUiMode>(() => (
33
- shouldStartImmersive() ? "immersive" : "chrome"
34
- ));
32
+ const [uiMode, setUiMode] = useState<PresentationUiMode>(() => {
33
+ if (shouldStartImmersive()) return "immersive";
34
+ // Fullscreen may already be active if requestFullscreen() was called
35
+ // synchronously in the workbench click handler before in-place navigation.
36
+ if (typeof globalThis.document !== "undefined" && globalThis.document.fullscreenElement) return "immersive";
37
+ return "chrome";
38
+ });
35
39
  const pageViewport = usePageViewportScale({
36
40
  stageRef,
37
41
  pageContainerRef: sourceContainerRef,
@@ -98,36 +98,9 @@ export function WorkbenchToolbarActions({
98
98
  </button>
99
99
  </div>
100
100
  ) : null}
101
- <div className="openpress-workbench-toolbar__group" aria-label="匯出">
102
- <ExportControl
103
- pages={pages}
104
- currentPageIndex={currentPageIndex}
105
- pressTitle={pressTitle}
106
- theme={theme}
107
- onExportPdf={onExportPdf}
108
- pdfDisabled={pdfDisabled}
109
- pdfLabel={pdfLabel}
110
- pdfStatusMessage={pdfStatusMessage}
111
- pdfActionStatus={pdfActionStatus}
112
- />
113
- </div>
114
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
115
- {isSlidePress && onOpenPresentation ? (
116
- <button
117
- type="button"
118
- className="openpress-workbench-toolbar-action"
119
- data-openpress-slide-present
120
- data-openpress-toolbar-expanded="false"
121
- data-openpress-toolbar-active="false"
122
- aria-pressed="false"
123
- title="進入放映模式"
124
- aria-label="進入放映模式"
125
- onClick={() => onOpenPresentation(currentPageIndex)}
126
- >
127
- <Play aria-hidden="true" />
128
- <span className="openpress-workbench-toolbar-action__label">放映</span>
129
- </button>
130
- ) : null}
101
+
102
+ {/* Center group: page geometry / zoom + workspace tools */}
103
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格與工具">
131
104
  <button
132
105
  type="button"
133
106
  className="openpress-workbench-page-geometry"
@@ -146,25 +119,15 @@ export function WorkbenchToolbarActions({
146
119
  onScaleModeChange={onScaleModeChange}
147
120
  onPageLayoutModeChange={onPageLayoutModeChange}
148
121
  />
149
- </div>
150
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
122
+ {workspaceMode ? (
123
+ <span className="openpress-workbench-toolbar__sep" aria-hidden="true" />
124
+ ) : null}
151
125
  {workspaceMode ? (
152
126
  <SearchControl
153
127
  sourceBlocksByPath={sourceBlocksByPath}
154
128
  onSelectPage={onSelectPage}
155
129
  />
156
130
  ) : null}
157
- {workspaceMode && editStatusMessage ? (
158
- <span
159
- className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
160
- data-openpress-edit-status={inlineEditStatus.state}
161
- role="status"
162
- aria-live="polite"
163
- >
164
- {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
165
- <span>{editStatusMessage}</span>
166
- </span>
167
- ) : null}
168
131
  {workspaceMode ? (
169
132
  <button
170
133
  type="button"
@@ -183,6 +146,17 @@ export function WorkbenchToolbarActions({
183
146
  <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
184
147
  </button>
185
148
  ) : null}
149
+ {workspaceMode && editStatusMessage ? (
150
+ <span
151
+ className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
152
+ data-openpress-edit-status={inlineEditStatus.state}
153
+ role="status"
154
+ aria-live="polite"
155
+ >
156
+ {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
157
+ <span>{editStatusMessage}</span>
158
+ </span>
159
+ ) : null}
186
160
  {workspaceMode && inspectorMode ? (
187
161
  <span
188
162
  className="openpress-dev-inspector-status"
@@ -193,6 +167,21 @@ export function WorkbenchToolbarActions({
193
167
  {inspectorCommentStatusMessage}
194
168
  </span>
195
169
  ) : null}
170
+ </div>
171
+
172
+ {/* Right group: export + deploy + present */}
173
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="匯出與發布">
174
+ <ExportControl
175
+ pages={pages}
176
+ currentPageIndex={currentPageIndex}
177
+ pressTitle={pressTitle}
178
+ theme={theme}
179
+ onExportPdf={onExportPdf}
180
+ pdfDisabled={pdfDisabled}
181
+ pdfLabel={pdfLabel}
182
+ pdfStatusMessage={pdfStatusMessage}
183
+ pdfActionStatus={pdfActionStatus}
184
+ />
196
185
  {localDeployEnabled ? (
197
186
  <DeploymentControl
198
187
  info={deploymentInfo}
@@ -200,6 +189,20 @@ export function WorkbenchToolbarActions({
200
189
  onDeploy={onDeploy}
201
190
  />
202
191
  ) : null}
192
+ {isSlidePress && onOpenPresentation ? (
193
+ <button
194
+ type="button"
195
+ className="openpress-workbench-toolbar-action openpress-workbench-toolbar-action--primary"
196
+ data-openpress-slide-present
197
+ aria-pressed="false"
198
+ title="進入放映模式"
199
+ aria-label="進入放映模式"
200
+ onClick={() => onOpenPresentation(currentPageIndex)}
201
+ >
202
+ <Play aria-hidden="true" />
203
+ <span className="openpress-workbench-toolbar-action__label">放映</span>
204
+ </button>
205
+ ) : null}
203
206
  </div>
204
207
  </>
205
208
  );
@@ -235,6 +235,15 @@
235
235
  height: 1px;
236
236
  }
237
237
 
238
+ .openpress-workbench-toolbar__sep {
239
+ display: block;
240
+ width: 1px;
241
+ height: 16px;
242
+ flex: 0 0 auto;
243
+ background: rgb(255 255 255 / 10%);
244
+ border-radius: 1px;
245
+ }
246
+
238
247
  .openpress-workbench-toolbar-action {
239
248
  position: relative;
240
249
  display: inline-flex;
@@ -279,6 +288,30 @@
279
288
  opacity: 0.62;
280
289
  }
281
290
 
291
+ .openpress-workbench-toolbar-action--primary {
292
+ width: auto;
293
+ min-width: 30px;
294
+ max-width: min(34vw, 300px);
295
+ gap: 7px;
296
+ padding: 0 12px;
297
+ background: var(--openpress-accent, #df4b21);
298
+ border-color: transparent;
299
+ color: #fff;
300
+ }
301
+
302
+ .openpress-workbench-toolbar-action--primary .openpress-workbench-toolbar-action__label {
303
+ display: inline-flex;
304
+ }
305
+
306
+ .openpress-workbench-toolbar-action--primary:hover:not(:disabled) {
307
+ background: color-mix(in srgb, var(--openpress-accent, #df4b21) 82%, #fff);
308
+ color: #fff;
309
+ }
310
+
311
+ .openpress-workbench-toolbar-action--primary:active:not(:disabled) {
312
+ transform: translateY(1px);
313
+ }
314
+
282
315
  .openpress-workbench-toolbar-action[data-openpress-toolbar-expanded="true"] {
283
316
  width: auto;
284
317
  min-width: 30px;
@@ -4,10 +4,8 @@
4
4
  load the document directly, so this CSS is dormant until users
5
5
  add a second <Press> to their <Workspace>.
6
6
 
7
- Layout intent: Figma-style file grid uniform card width, fixed
8
- thumbnail aspect ratio (4:3), filename + meta below. Each card
9
- loads its first page asynchronously and renders it scaled-down
10
- inside the thumbnail slot. */
7
+ Layout: full-height dark page, header + two-column body
8
+ (narrow left sidebar for type filter, fluid right grid). */
11
9
 
12
10
  .openpress-workspace-gallery {
13
11
  --workspace-bg: #10110f;
@@ -20,10 +18,10 @@
20
18
  --workspace-card-muted: #65635d;
21
19
  --workspace-card-line: rgba(20, 20, 17, 0.1);
22
20
  --workspace-card-stage: #e8e5dc;
23
- display: grid;
24
- gap: 2rem;
21
+ --workspace-sidebar-w: 180px;
22
+ display: flex;
23
+ flex-direction: column;
25
24
  min-height: 100vh;
26
- max-width: none;
27
25
  margin: 0;
28
26
  padding: 3.6rem clamp(2rem, 4vw, 4.5rem) 6rem;
29
27
  font-family: var(--openpress-font-body, system-ui, sans-serif);
@@ -31,11 +29,13 @@
31
29
  background:
32
30
  linear-gradient(180deg, var(--workspace-bg-soft), var(--workspace-bg) 42rem),
33
31
  var(--workspace-bg);
32
+ gap: 2.25rem;
34
33
  }
35
34
 
35
+ /* ── Header ──────────────────────────────────────────────── */
36
+
36
37
  .openpress-workspace-gallery__header {
37
- display: grid;
38
- grid-template-columns: minmax(0, 1fr) auto;
38
+ display: flex;
39
39
  align-items: end;
40
40
  justify-content: space-between;
41
41
  gap: 2.5rem;
@@ -48,50 +48,114 @@
48
48
  gap: 0.75rem;
49
49
  }
50
50
 
51
- .openpress-workspace-gallery__eyebrow {
51
+ .openpress-workspace-gallery__brand {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 0.5rem;
52
55
  margin: 0;
53
- color: var(--workspace-muted);
54
56
  font-family: var(--openpress-font-mono, ui-monospace, monospace);
55
57
  font-size: 0.68rem;
56
58
  font-weight: 600;
57
- letter-spacing: 0.16em;
59
+ letter-spacing: 0.12em;
58
60
  text-transform: uppercase;
59
61
  }
60
62
 
63
+ .openpress-workspace-gallery__brand-mark {
64
+ color: var(--workspace-ink);
65
+ }
66
+
67
+ .openpress-workspace-gallery__brand-sep {
68
+ color: var(--workspace-muted);
69
+ letter-spacing: 0;
70
+ }
71
+
72
+ .openpress-workspace-gallery__eyebrow {
73
+ color: var(--workspace-muted);
74
+ }
75
+
61
76
  .openpress-workspace-gallery__header h1 {
62
77
  margin: 0;
63
78
  font-family: var(--openpress-font-display, var(--openpress-font-body, system-ui));
64
- font-size: clamp(2.6rem, 5.4vw, 5.2rem);
65
- font-weight: 720;
66
- line-height: 0.94;
67
- letter-spacing: -0.035em;
79
+ font-size: clamp(1.4rem, 2.6vw, 2.2rem);
80
+ font-weight: 600;
81
+ line-height: 1.1;
82
+ letter-spacing: -0.02em;
68
83
  color: var(--workspace-ink);
69
84
  }
70
85
 
71
- .openpress-workspace-gallery__count {
72
- margin: 0;
86
+ /* ── Body: sidebar + grid ────────────────────────────────── */
87
+
88
+ .openpress-workspace-gallery__body {
73
89
  display: grid;
74
- justify-items: end;
75
- gap: 0.25rem;
76
- min-width: 4.5rem;
90
+ grid-template-columns: var(--workspace-sidebar-w) 1fr;
91
+ align-items: start;
92
+ gap: 2.5rem;
93
+ }
94
+
95
+ /* ── Left sidebar ────────────────────────────────────────── */
96
+
97
+ .openpress-workspace-gallery__sidebar {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 2px;
101
+ position: sticky;
102
+ top: 1.5rem;
103
+ }
104
+
105
+ .openpress-workspace-gallery__filter-btn {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: space-between;
109
+ gap: 0.6rem;
110
+ width: 100%;
111
+ padding: 0.52rem 0.75rem;
112
+ border: 1px solid transparent;
113
+ border-radius: 7px;
114
+ background: transparent;
115
+ color: var(--workspace-muted);
116
+ font-family: var(--openpress-font-body, system-ui, sans-serif);
117
+ font-size: 0.82rem;
118
+ font-weight: 500;
119
+ text-align: left;
120
+ cursor: pointer;
121
+ transition:
122
+ background 140ms ease,
123
+ color 140ms ease,
124
+ border-color 140ms ease;
125
+ }
126
+
127
+ .openpress-workspace-gallery__filter-btn:hover {
128
+ background: rgba(244, 241, 232, 0.06);
77
129
  color: var(--workspace-ink);
78
- font-family: var(--openpress-font-mono, ui-monospace, monospace);
79
- line-height: 1;
80
130
  }
81
131
 
82
- .openpress-workspace-gallery__count span {
132
+ .openpress-workspace-gallery__filter-btn[data-active="true"] {
133
+ background: rgba(244, 241, 232, 0.1);
134
+ border-color: rgba(244, 241, 232, 0.14);
83
135
  color: var(--workspace-ink);
84
- font-size: 2rem;
85
- font-weight: 500;
86
- letter-spacing: -0.04em;
87
136
  }
88
137
 
89
- .openpress-workspace-gallery__count small {
138
+ .openpress-workspace-gallery__filter-label {
139
+ flex: 1 1 auto;
140
+ }
141
+
142
+ .openpress-workspace-gallery__filter-count {
143
+ flex: 0 0 auto;
144
+ font-family: var(--openpress-font-mono, ui-monospace, monospace);
145
+ font-size: 0.72rem;
146
+ font-weight: 500;
90
147
  color: var(--workspace-muted);
91
- font-size: 0.62rem;
92
- font-weight: 600;
93
- letter-spacing: 0.14em;
94
- text-transform: uppercase;
148
+ letter-spacing: 0.04em;
149
+ }
150
+
151
+ .openpress-workspace-gallery__filter-btn[data-active="true"] .openpress-workspace-gallery__filter-count {
152
+ color: var(--workspace-ink);
153
+ }
154
+
155
+ /* ── Main grid ───────────────────────────────────────────── */
156
+
157
+ .openpress-workspace-gallery__main {
158
+ min-width: 0;
95
159
  }
96
160
 
97
161
  .openpress-workspace-gallery__grid {
@@ -100,8 +164,6 @@
100
164
  padding: 0;
101
165
  display: grid;
102
166
  align-items: start;
103
- /* Uniform card size — Figma-style. Outer thumb is fixed 4:3, the
104
- inner page letterboxes to its own geometry. */
105
167
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
106
168
  gap: 1.5rem;
107
169
  }
@@ -110,6 +172,15 @@
110
172
  display: flex;
111
173
  }
112
174
 
175
+ .openpress-workspace-gallery__empty {
176
+ margin: 0;
177
+ padding: 3rem 0;
178
+ color: var(--workspace-muted);
179
+ font-size: 0.88rem;
180
+ }
181
+
182
+ /* ── Card ────────────────────────────────────────────────── */
183
+
113
184
  .openpress-workspace-gallery__card {
114
185
  appearance: none;
115
186
  display: grid;
@@ -138,13 +209,12 @@
138
209
  outline: none;
139
210
  }
140
211
 
212
+ /* ── Thumbnail ───────────────────────────────────────────── */
213
+
141
214
  .openpress-workspace-gallery__thumb {
142
215
  position: relative;
143
216
  display: block;
144
217
  width: 100%;
145
- /* Uniform 4:3 outer slot across every card. The page itself
146
- letterboxes inside via centered scale, so each Press shows at its
147
- own true aspect against the gradient background. */
148
218
  aspect-ratio: 4 / 3;
149
219
  background:
150
220
  linear-gradient(
@@ -220,7 +290,9 @@
220
290
  50% { opacity: 0.55; }
221
291
  }
222
292
 
223
- .openpress-workspace-gallery__body {
293
+ /* ── Card body ───────────────────────────────────────────── */
294
+
295
+ .openpress-workspace-gallery__card-body {
224
296
  display: grid;
225
297
  align-content: space-between;
226
298
  gap: 1.2rem;
@@ -262,10 +334,6 @@
262
334
  white-space: nowrap;
263
335
  }
264
336
 
265
- .openpress-workspace-gallery__geom {
266
- color: var(--workspace-card-muted);
267
- }
268
-
269
337
  .openpress-workspace-gallery__geom {
270
338
  display: inline-flex;
271
339
  align-items: center;
@@ -279,16 +347,31 @@
279
347
  white-space: nowrap;
280
348
  }
281
349
 
350
+ /* ── Responsive ──────────────────────────────────────────── */
351
+
352
+ @media (max-width: 860px) {
353
+ .openpress-workspace-gallery__body {
354
+ grid-template-columns: 1fr;
355
+ }
356
+
357
+ .openpress-workspace-gallery__sidebar {
358
+ position: static;
359
+ flex-direction: row;
360
+ flex-wrap: wrap;
361
+ gap: 6px;
362
+ }
363
+
364
+ .openpress-workspace-gallery__filter-btn {
365
+ width: auto;
366
+ flex: 0 0 auto;
367
+ }
368
+ }
369
+
282
370
  @media (max-width: 720px) {
283
371
  .openpress-workspace-gallery {
284
372
  padding: 2.25rem 1rem 4rem;
285
373
  }
286
374
 
287
- .openpress-workspace-gallery__header {
288
- display: grid;
289
- align-items: start;
290
- }
291
-
292
375
  .openpress-workspace-gallery__grid {
293
376
  grid-template-columns: 1fr;
294
377
  }