@nexpress/theme-portfolio 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1396 @@
1
+ // src/index.ts
2
+ import { defineTheme } from "@nexpress/theme";
3
+
4
+ // src/settings-helpers.ts
5
+ import { getCachedThemeSettings } from "@nexpress/next";
6
+
7
+ // src/settings.ts
8
+ import { z } from "zod";
9
+ var portfolioSettingsSchema = z.object({
10
+ // Layout
11
+ gridColumns: z.number().int().min(1).max(6).default(3).describe("Number of columns in the project archive grid (1\u20136)."),
12
+ cardAspect: z.enum(["square", "portrait", "landscape", "golden"]).default("square").describe(
13
+ "Aspect ratio of project cards: square (1:1), portrait (3:4), landscape (4:3), or golden (1:1.618)."
14
+ ),
15
+ hoverStyle: z.enum(["fade", "scale", "slide", "lift"]).default("fade").describe(
16
+ "Hover effect on project cards. fade: caption fades in. scale: image zooms 1.05x. slide: caption slides up. lift: card lifts with shadow."
17
+ ),
18
+ galleryGutter: z.number().int().min(0).max(64).default(16).describe("Gap between project cards in pixels (0\u201364)."),
19
+ // Project meta
20
+ showProjectMeta: z.boolean().default(true).describe("Show role / year / client meta strip on project detail pages."),
21
+ showProjectTags: z.boolean().default(false).describe("Show tag chips below project titles on the index grid."),
22
+ // Brand
23
+ accentColor: z.string().regex(/^#[0-9a-f]{6}$/i).optional().describe(
24
+ "Optional accent color override (hex). Used for hover states and the masthead underline."
25
+ ),
26
+ studioName: z.string().default("Studio").describe("Studio / personal name shown in the masthead and footer."),
27
+ aboutCopy: z.string().default("").meta({ widget: "textarea", rows: 4 }).describe(
28
+ "Optional short bio for the studio. Renders as a multi-line textarea in admin (4 rows) and as a small paragraph above the footer contact line on the public site."
29
+ ),
30
+ // Footer
31
+ showFooterCredit: z.boolean().default(true).describe(
32
+ "Show 'Built with NexPress' credit in the footer. Some studios prefer an unbranded footer."
33
+ ),
34
+ copyrightYear: z.number().int().min(2e3).max(2100).optional().describe(
35
+ "Optional fixed copyright year. Defaults to the current year when omitted."
36
+ ),
37
+ // Client logos
38
+ clientLogos: z.array(
39
+ z.object({
40
+ name: z.string().describe("Client name (alt text + caption)"),
41
+ logoUrl: z.string().url().describe("Logo image URL"),
42
+ link: z.string().url().optional().describe("Optional case-study link")
43
+ })
44
+ ).default([]).describe(
45
+ "Client logos rendered in the homepage 'Selected clients' strip. Edit per project."
46
+ )
47
+ });
48
+
49
+ // src/settings-helpers.ts
50
+ async function resolvePortfolioSettings() {
51
+ const raw = await getCachedThemeSettings("portfolio");
52
+ const parsed = portfolioSettingsSchema.safeParse(raw);
53
+ if (parsed.success) return parsed.data;
54
+ return portfolioSettingsSchema.parse({});
55
+ }
56
+
57
+ // src/blocks.tsx
58
+ import { jsx, jsxs } from "react/jsx-runtime";
59
+ function CaseStudyHero(props) {
60
+ const { title, subtitle, client, year, role, imageUrl } = props;
61
+ return /* @__PURE__ */ jsx(
62
+ "section",
63
+ {
64
+ className: "np-portfolio-case-study-hero",
65
+ style: {
66
+ position: "relative",
67
+ margin: "0 0 2rem",
68
+ padding: 0,
69
+ minHeight: imageUrl ? "60vh" : "auto",
70
+ backgroundImage: imageUrl ? `url(${imageUrl})` : void 0,
71
+ backgroundSize: "cover",
72
+ backgroundPosition: "center",
73
+ color: imageUrl ? "white" : "inherit",
74
+ display: "flex",
75
+ flexDirection: "column",
76
+ justifyContent: "flex-end"
77
+ },
78
+ children: /* @__PURE__ */ jsxs(
79
+ "div",
80
+ {
81
+ style: {
82
+ padding: "3rem 1.5rem 2rem",
83
+ background: imageUrl ? "linear-gradient(180deg, transparent, rgba(0,0,0,0.65))" : void 0
84
+ },
85
+ children: [
86
+ /* @__PURE__ */ jsx(
87
+ "h1",
88
+ {
89
+ style: {
90
+ fontFamily: "var(--np-font-heading)",
91
+ fontSize: "clamp(2rem, 5vw, 3.75rem)",
92
+ fontWeight: 600,
93
+ margin: 0,
94
+ letterSpacing: "-0.02em"
95
+ },
96
+ children: title
97
+ }
98
+ ),
99
+ subtitle ? /* @__PURE__ */ jsx(
100
+ "p",
101
+ {
102
+ style: {
103
+ margin: "0.75rem 0 0",
104
+ fontSize: "1.125rem",
105
+ maxWidth: "60ch",
106
+ opacity: 0.9
107
+ },
108
+ children: subtitle
109
+ }
110
+ ) : null,
111
+ client || year || role ? /* @__PURE__ */ jsxs(
112
+ "div",
113
+ {
114
+ style: {
115
+ display: "flex",
116
+ flexWrap: "wrap",
117
+ gap: "2rem",
118
+ marginTop: "1.5rem",
119
+ fontSize: "0.875rem",
120
+ opacity: 0.8
121
+ },
122
+ children: [
123
+ client ? /* @__PURE__ */ jsxs("div", { children: [
124
+ /* @__PURE__ */ jsx("span", { style: { display: "block", opacity: 0.6, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.08em" }, children: "Client" }),
125
+ client
126
+ ] }) : null,
127
+ year ? /* @__PURE__ */ jsxs("div", { children: [
128
+ /* @__PURE__ */ jsx("span", { style: { display: "block", opacity: 0.6, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.08em" }, children: "Year" }),
129
+ year
130
+ ] }) : null,
131
+ role ? /* @__PURE__ */ jsxs("div", { children: [
132
+ /* @__PURE__ */ jsx("span", { style: { display: "block", opacity: 0.6, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.08em" }, children: "Role" }),
133
+ role
134
+ ] }) : null
135
+ ]
136
+ }
137
+ ) : null
138
+ ]
139
+ }
140
+ )
141
+ }
142
+ );
143
+ }
144
+ function ImageGrid(props) {
145
+ const { columns, items } = props;
146
+ const cols = typeof columns === "number" && columns > 0 ? columns : 2;
147
+ return /* @__PURE__ */ jsx(
148
+ "section",
149
+ {
150
+ className: "np-portfolio-image-grid",
151
+ style: {
152
+ margin: "2rem 0",
153
+ display: "grid",
154
+ gap: "1rem",
155
+ gridTemplateColumns: `repeat(${cols}, 1fr)`
156
+ },
157
+ children: items.map((item, i) => /* @__PURE__ */ jsxs("figure", { style: { margin: 0 }, children: [
158
+ /* @__PURE__ */ jsx(
159
+ "img",
160
+ {
161
+ src: item.url,
162
+ alt: item.alt ?? "",
163
+ style: {
164
+ display: "block",
165
+ width: "100%",
166
+ height: "auto",
167
+ borderRadius: "0.25rem"
168
+ }
169
+ }
170
+ ),
171
+ item.caption ? /* @__PURE__ */ jsx(
172
+ "figcaption",
173
+ {
174
+ style: {
175
+ fontSize: "0.8125rem",
176
+ color: "var(--np-color-muted-foreground)",
177
+ marginTop: "0.5rem"
178
+ },
179
+ children: item.caption
180
+ }
181
+ ) : null
182
+ ] }, i))
183
+ }
184
+ );
185
+ }
186
+ var portfolioBlocks = [
187
+ {
188
+ type: "portfolio.case-study-hero",
189
+ label: "Case study hero",
190
+ iconKind: "lucide",
191
+ icon: "image",
192
+ keywords: ["hero", "case-study", "portfolio", "project"],
193
+ defaultProps: {
194
+ title: "Project name",
195
+ subtitle: "One-sentence project summary.",
196
+ client: "Client name",
197
+ year: "2026",
198
+ role: "Design + Engineering",
199
+ imageUrl: ""
200
+ },
201
+ propsSchema: [
202
+ { name: "title", label: "Project title", type: "text" },
203
+ { name: "subtitle", label: "Subtitle", type: "textarea" },
204
+ { name: "client", label: "Client", type: "text" },
205
+ { name: "year", label: "Year", type: "text" },
206
+ { name: "role", label: "Role", type: "text" },
207
+ { name: "imageUrl", label: "Hero image URL", type: "url" }
208
+ ],
209
+ render: (props) => /* @__PURE__ */ jsx(CaseStudyHero, { ...props })
210
+ },
211
+ {
212
+ type: "portfolio.image-grid",
213
+ label: "Image grid",
214
+ iconKind: "lucide",
215
+ icon: "grid-3x3",
216
+ keywords: ["images", "gallery", "grid", "portfolio"],
217
+ defaultProps: {
218
+ columns: 2,
219
+ items: [
220
+ { url: "https://placehold.co/800x600", alt: "", caption: "" },
221
+ { url: "https://placehold.co/800x600", alt: "", caption: "" }
222
+ ]
223
+ },
224
+ propsSchema: [
225
+ { name: "columns", label: "Columns", type: "number" },
226
+ // `items` edited as JSON in v0.2; richer per-item editor
227
+ // (drag-to-reorder, add/remove) tracked as F.5.1 polish.
228
+ { name: "items", label: "Items (JSON)", type: "textarea" }
229
+ ],
230
+ render: (props) => /* @__PURE__ */ jsx(ImageGrid, { ...props })
231
+ },
232
+ {
233
+ type: "portfolio.client-logos",
234
+ label: "Client logos strip",
235
+ iconKind: "lucide",
236
+ icon: "users",
237
+ keywords: ["clients", "logos", "portfolio", "selected work"],
238
+ defaultProps: {
239
+ heading: "Selected clients"
240
+ },
241
+ propsSchema: [
242
+ { name: "heading", label: "Section heading", type: "text" }
243
+ ],
244
+ // `ClientLogosStrip` is itself an async server component —
245
+ // it reads `settings.clientLogos` so the operator manages
246
+ // logos in admin's Theme settings panel (a single canonical
247
+ // source) rather than re-typing per block instance. The render
248
+ // arrow itself is sync (just constructs the element); React
249
+ // resolves the inner async at render time.
250
+ render: (props) => /* @__PURE__ */ jsx(ClientLogosStrip, { ...props })
251
+ }
252
+ ];
253
+ async function ClientLogosStrip({
254
+ heading
255
+ }) {
256
+ const settings = await resolvePortfolioSettings();
257
+ const logos = settings.clientLogos;
258
+ if (logos.length === 0) return null;
259
+ return /* @__PURE__ */ jsxs(
260
+ "section",
261
+ {
262
+ className: "np-portfolio-client-logos",
263
+ style: {
264
+ margin: "3rem 0"
265
+ },
266
+ children: [
267
+ heading ? /* @__PURE__ */ jsx(
268
+ "h2",
269
+ {
270
+ style: {
271
+ margin: "0 0 1.5rem",
272
+ fontSize: "0.8125rem",
273
+ textTransform: "uppercase",
274
+ letterSpacing: "0.1em",
275
+ color: "var(--np-color-muted-foreground)",
276
+ textAlign: "center"
277
+ },
278
+ children: heading
279
+ }
280
+ ) : null,
281
+ /* @__PURE__ */ jsx(
282
+ "div",
283
+ {
284
+ style: {
285
+ display: "grid",
286
+ gridTemplateColumns: `repeat(auto-fit, minmax(140px, 1fr))`,
287
+ gap: "2rem",
288
+ alignItems: "center",
289
+ justifyItems: "center"
290
+ },
291
+ children: logos.map((logo, i) => {
292
+ const img = /* @__PURE__ */ jsx(
293
+ "img",
294
+ {
295
+ src: logo.logoUrl,
296
+ alt: logo.name,
297
+ style: {
298
+ maxHeight: "48px",
299
+ maxWidth: "100%",
300
+ opacity: 0.7,
301
+ transition: "opacity 0.2s ease"
302
+ }
303
+ }
304
+ );
305
+ return /* @__PURE__ */ jsx("div", { children: logo.link ? /* @__PURE__ */ jsx("a", { href: logo.link, target: "_blank", rel: "noreferrer", children: img }) : img }, `portfolio-logo-${i.toString()}`);
306
+ })
307
+ }
308
+ )
309
+ ]
310
+ }
311
+ );
312
+ }
313
+
314
+ // src/index.ts
315
+ import { PortfolioMobileNav as PortfolioMobileNav2 } from "./components/mobile-nav.js";
316
+
317
+ // src/components/project-card.tsx
318
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
319
+ function coverUrl(value) {
320
+ if (!value) return null;
321
+ if (typeof value === "string") return value;
322
+ return value.url ?? null;
323
+ }
324
+ function coverAlt(value, fallback) {
325
+ if (value && typeof value === "object" && value.alt) return value.alt;
326
+ return fallback;
327
+ }
328
+ function projectHref(doc) {
329
+ if (doc.slug) {
330
+ return doc.slug.startsWith("/") ? doc.slug : `/work/${doc.slug}`;
331
+ }
332
+ return "#";
333
+ }
334
+ async function PortfolioProjectCard({
335
+ doc
336
+ }) {
337
+ const settings = await resolvePortfolioSettings();
338
+ const href = projectHref(doc);
339
+ const cover = coverUrl(doc.cover);
340
+ const title = doc.title ?? "Untitled";
341
+ return /* @__PURE__ */ jsx2("a", { href, className: "np-portfolio-project-card", children: /* @__PURE__ */ jsxs2("figure", { className: "np-portfolio-project-cover", children: [
342
+ cover ? /* @__PURE__ */ jsx2("img", { src: cover, alt: coverAlt(doc.cover, title), loading: "lazy" }) : /* @__PURE__ */ jsx2("span", { className: "np-portfolio-project-placeholder", "aria-hidden": "true" }),
343
+ /* @__PURE__ */ jsxs2("figcaption", { className: "np-portfolio-project-caption", children: [
344
+ /* @__PURE__ */ jsx2("span", { className: "np-portfolio-project-title", children: title }),
345
+ settings.showProjectTags && doc.category ? /* @__PURE__ */ jsx2("span", { className: "np-portfolio-project-category", children: doc.category }) : null
346
+ ] })
347
+ ] }) });
348
+ }
349
+
350
+ // src/footer.tsx
351
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
352
+ async function PortfolioFooter() {
353
+ const settings = await resolvePortfolioSettings();
354
+ const year = settings.copyrightYear ?? (/* @__PURE__ */ new Date()).getFullYear();
355
+ const email = process.env.NP_SOCIAL_EMAIL;
356
+ const social = [
357
+ { href: process.env.NP_SOCIAL_GITHUB, label: "GitHub" },
358
+ { href: process.env.NP_SOCIAL_TWITTER, label: "Twitter" },
359
+ { href: process.env.NP_SOCIAL_LINKEDIN, label: "LinkedIn" },
360
+ { href: process.env.NP_SOCIAL_MASTODON, label: "Mastodon" },
361
+ { href: process.env.NP_SOCIAL_DRIBBBLE, label: "Dribbble" },
362
+ { href: process.env.NP_SOCIAL_INSTAGRAM, label: "Instagram" }
363
+ ].filter((s) => Boolean(s.href));
364
+ const studio = settings.studioName;
365
+ return /* @__PURE__ */ jsx3("footer", { className: "np-site-footer np-portfolio-footer", children: /* @__PURE__ */ jsxs3("div", { className: "np-portfolio-footer-inner", children: [
366
+ settings.aboutCopy.length > 0 ? /* @__PURE__ */ jsx3(
367
+ "p",
368
+ {
369
+ className: "np-portfolio-footer-bio",
370
+ style: {
371
+ maxWidth: "60ch",
372
+ fontSize: "0.875rem",
373
+ color: "var(--np-color-muted-foreground)",
374
+ margin: "0 0 1.25rem"
375
+ },
376
+ children: settings.aboutCopy
377
+ }
378
+ ) : null,
379
+ /* @__PURE__ */ jsx3("div", { className: "np-portfolio-footer-contact", children: email ? /* @__PURE__ */ jsx3(
380
+ "a",
381
+ {
382
+ href: email.startsWith("mailto:") ? email : `mailto:${email}`,
383
+ className: "np-portfolio-footer-email",
384
+ children: email.replace(/^mailto:/, "")
385
+ }
386
+ ) : /* @__PURE__ */ jsx3("span", { className: "np-portfolio-footer-email", children: "Available for select work" }) }),
387
+ social.length > 0 ? /* @__PURE__ */ jsx3("ul", { className: "np-portfolio-footer-social", children: social.map((s) => /* @__PURE__ */ jsx3("li", { children: /* @__PURE__ */ jsx3("a", { href: s.href, target: "_blank", rel: "noopener noreferrer", children: s.label }) }, s.href)) }) : null,
388
+ /* @__PURE__ */ jsxs3("p", { className: "np-portfolio-footer-mark", children: [
389
+ "\xA9 ",
390
+ year.toString(),
391
+ " \xB7 ",
392
+ studio,
393
+ settings.showFooterCredit ? " \xB7 Built with NexPress" : ""
394
+ ] })
395
+ ] }) });
396
+ }
397
+
398
+ // src/header.tsx
399
+ import { getCachedNavigation } from "@nexpress/next";
400
+ import { PortfolioMobileNav } from "./components/mobile-nav.js";
401
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
402
+ async function PortfolioHeader() {
403
+ const items = await getCachedNavigation("header");
404
+ const settings = await resolvePortfolioSettings();
405
+ return /* @__PURE__ */ jsxs4("header", { className: "np-site-header np-portfolio-header", children: [
406
+ /* @__PURE__ */ jsx4("a", { href: "/", className: "np-portfolio-logo", children: settings.studioName }),
407
+ items.length > 0 ? /* @__PURE__ */ jsxs4(Fragment, { children: [
408
+ /* @__PURE__ */ jsx4("nav", { "aria-label": "Main", className: "np-portfolio-nav-desktop", children: /* @__PURE__ */ jsx4("ul", { className: "np-portfolio-nav", children: items.map((item, index) => /* @__PURE__ */ jsxs4("li", { className: "np-portfolio-nav-item", children: [
409
+ /* @__PURE__ */ jsx4("a", { href: item.url, children: item.label }),
410
+ item.children && item.children.length > 0 ? /* @__PURE__ */ jsx4("ul", { className: "np-portfolio-subnav", children: item.children.map((child, childIndex) => /* @__PURE__ */ jsx4("li", { children: /* @__PURE__ */ jsx4("a", { href: child.url, children: child.label }) }, `portfolio-nav-${index.toString()}-${childIndex.toString()}`)) }) : null
411
+ ] }, `portfolio-nav-${index.toString()}`)) }) }),
412
+ /* @__PURE__ */ jsx4(PortfolioMobileNav, { items })
413
+ ] }) : null
414
+ ] });
415
+ }
416
+
417
+ // src/members-not-found.tsx
418
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
419
+ function PortfolioMembersNotFound() {
420
+ return /* @__PURE__ */ jsxs5(
421
+ "div",
422
+ {
423
+ className: "np-portfolio-members-not-found",
424
+ style: {
425
+ maxWidth: 480,
426
+ margin: "6rem auto",
427
+ padding: "0 1.5rem",
428
+ textAlign: "center"
429
+ },
430
+ children: [
431
+ /* @__PURE__ */ jsx5(
432
+ "p",
433
+ {
434
+ style: {
435
+ margin: 0,
436
+ fontSize: "0.75rem",
437
+ textTransform: "uppercase",
438
+ letterSpacing: "0.18em",
439
+ color: "var(--np-color-muted-foreground)",
440
+ fontFamily: "var(--np-font-body)"
441
+ },
442
+ children: "Account"
443
+ }
444
+ ),
445
+ /* @__PURE__ */ jsx5(
446
+ "h1",
447
+ {
448
+ style: {
449
+ margin: "1rem 0 0",
450
+ fontSize: "clamp(1.75rem, 4vw, 2.5rem)",
451
+ fontFamily: "var(--np-font-heading)",
452
+ fontWeight: 500,
453
+ letterSpacing: "-0.02em"
454
+ },
455
+ children: "Link no longer valid."
456
+ }
457
+ ),
458
+ /* @__PURE__ */ jsx5(
459
+ "p",
460
+ {
461
+ style: {
462
+ margin: "1.25rem 0 0",
463
+ color: "var(--np-color-muted-foreground)",
464
+ fontSize: "0.9375rem",
465
+ lineHeight: 1.6
466
+ },
467
+ children: "Verification and password-reset links are single-use and short-lived. Request a fresh one from the sign-in page."
468
+ }
469
+ ),
470
+ /* @__PURE__ */ jsx5(
471
+ "a",
472
+ {
473
+ href: "/members/login",
474
+ style: {
475
+ display: "inline-block",
476
+ marginTop: "2rem",
477
+ padding: "0.625rem 1.5rem",
478
+ borderRadius: "0.25rem",
479
+ background: "var(--np-color-primary)",
480
+ color: "var(--np-color-primary-foreground)",
481
+ textDecoration: "none",
482
+ fontSize: "0.875rem",
483
+ fontWeight: 500,
484
+ letterSpacing: "0.02em"
485
+ },
486
+ children: "Go to sign in"
487
+ }
488
+ )
489
+ ]
490
+ }
491
+ );
492
+ }
493
+
494
+ // src/members-shell.tsx
495
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
496
+ var ASPECT_VALUES = {
497
+ square: "1 / 1",
498
+ portrait: "3 / 4",
499
+ landscape: "4 / 3",
500
+ golden: "1 / 1.618"
501
+ };
502
+ async function PortfolioMembersShell({
503
+ children
504
+ }) {
505
+ const settings = await resolvePortfolioSettings();
506
+ const aspect = ASPECT_VALUES[settings.cardAspect];
507
+ const styleVars = {
508
+ "--np-portfolio-card-aspect": aspect
509
+ };
510
+ if (settings.accentColor) {
511
+ styleVars["--np-color-primary"] = settings.accentColor;
512
+ }
513
+ return /* @__PURE__ */ jsxs6(
514
+ "div",
515
+ {
516
+ className: "np-portfolio",
517
+ "data-hover-style": settings.hoverStyle,
518
+ style: styleVars,
519
+ children: [
520
+ /* @__PURE__ */ jsx6(PortfolioHeader, {}),
521
+ /* @__PURE__ */ jsx6("div", { className: "np-portfolio-members", children: /* @__PURE__ */ jsx6("div", { className: "np-portfolio-members-column", children }) }),
522
+ /* @__PURE__ */ jsx6(PortfolioFooter, {})
523
+ ]
524
+ }
525
+ );
526
+ }
527
+
528
+ // src/not-found.tsx
529
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
530
+ function PortfolioNotFound() {
531
+ return /* @__PURE__ */ jsxs7(
532
+ "div",
533
+ {
534
+ className: "np-portfolio-not-found",
535
+ style: {
536
+ minHeight: "60vh",
537
+ maxWidth: 480,
538
+ margin: "0 auto",
539
+ padding: "6rem 1.5rem",
540
+ display: "flex",
541
+ flexDirection: "column",
542
+ justifyContent: "center",
543
+ alignItems: "center",
544
+ textAlign: "center"
545
+ },
546
+ children: [
547
+ /* @__PURE__ */ jsx7(
548
+ "p",
549
+ {
550
+ style: {
551
+ margin: 0,
552
+ fontSize: "0.75rem",
553
+ textTransform: "uppercase",
554
+ letterSpacing: "0.15em",
555
+ color: "var(--np-color-muted-foreground)"
556
+ },
557
+ children: "404"
558
+ }
559
+ ),
560
+ /* @__PURE__ */ jsx7(
561
+ "h1",
562
+ {
563
+ style: {
564
+ margin: "1rem 0 0",
565
+ fontSize: "clamp(1.75rem, 4vw, 2.5rem)",
566
+ fontWeight: 500,
567
+ letterSpacing: "-0.02em"
568
+ },
569
+ children: "Project not found"
570
+ }
571
+ ),
572
+ /* @__PURE__ */ jsx7(
573
+ "p",
574
+ {
575
+ style: {
576
+ margin: "1rem 0 2rem",
577
+ color: "var(--np-color-muted-foreground)",
578
+ fontSize: "0.9375rem"
579
+ },
580
+ children: "The page you're looking for moved or doesn't exist."
581
+ }
582
+ ),
583
+ /* @__PURE__ */ jsx7(
584
+ "a",
585
+ {
586
+ href: "/",
587
+ style: {
588
+ display: "inline-block",
589
+ padding: "0.5rem 1.5rem",
590
+ border: "1px solid var(--np-color-border)",
591
+ borderRadius: "0.25rem",
592
+ color: "var(--np-color-foreground)",
593
+ textDecoration: "none",
594
+ fontSize: "0.875rem"
595
+ },
596
+ children: "See selected work \u2192"
597
+ }
598
+ )
599
+ ]
600
+ }
601
+ );
602
+ }
603
+
604
+ // src/routes/project-detail.tsx
605
+ import { findDocuments } from "@nexpress/core";
606
+ import { notFound } from "next/navigation";
607
+
608
+ // src/templates/project-detail.tsx
609
+ import { renderBlocks } from "@nexpress/blocks";
610
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
611
+ function coverUrl2(value) {
612
+ if (!value) return null;
613
+ if (typeof value === "string") return value;
614
+ return value.url ?? null;
615
+ }
616
+ function coverAlt2(value, fallback) {
617
+ if (value && typeof value === "object" && value.alt) return value.alt;
618
+ return fallback;
619
+ }
620
+ async function ProjectDetailTemplate({
621
+ doc,
622
+ blockCtx
623
+ }) {
624
+ const project = doc;
625
+ const settings = await resolvePortfolioSettings();
626
+ const title = project.title ?? "Untitled";
627
+ const cover = coverUrl2(project.cover);
628
+ return /* @__PURE__ */ jsxs8("article", { className: "np-portfolio-project-detail", children: [
629
+ cover ? /* @__PURE__ */ jsx8("figure", { className: "np-portfolio-project-hero", children: /* @__PURE__ */ jsx8("img", { src: cover, alt: coverAlt2(project.cover, title) }) }) : null,
630
+ /* @__PURE__ */ jsxs8("header", { className: "np-portfolio-project-header", children: [
631
+ /* @__PURE__ */ jsx8("h1", { children: title }),
632
+ project.excerpt ? /* @__PURE__ */ jsx8("p", { className: "np-portfolio-project-excerpt", children: project.excerpt }) : null,
633
+ settings.showProjectMeta && (project.role || project.year || project.client) ? /* @__PURE__ */ jsxs8("dl", { className: "np-portfolio-project-meta", children: [
634
+ project.client ? /* @__PURE__ */ jsxs8(Fragment2, { children: [
635
+ /* @__PURE__ */ jsx8("dt", { children: "Client" }),
636
+ /* @__PURE__ */ jsx8("dd", { children: project.client })
637
+ ] }) : null,
638
+ project.role ? /* @__PURE__ */ jsxs8(Fragment2, { children: [
639
+ /* @__PURE__ */ jsx8("dt", { children: "Role" }),
640
+ /* @__PURE__ */ jsx8("dd", { children: project.role })
641
+ ] }) : null,
642
+ project.year ? /* @__PURE__ */ jsxs8(Fragment2, { children: [
643
+ /* @__PURE__ */ jsx8("dt", { children: "Year" }),
644
+ /* @__PURE__ */ jsx8("dd", { children: String(project.year) })
645
+ ] }) : null
646
+ ] }) : null
647
+ ] }),
648
+ project.blocks && project.blocks.length > 0 ? /* @__PURE__ */ jsx8("div", { className: "np-portfolio-project-body", children: renderBlocks(project.blocks, { ctx: blockCtx }) }) : null
649
+ ] });
650
+ }
651
+
652
+ // src/routes/project-detail.tsx
653
+ import { jsx as jsx9 } from "react/jsx-runtime";
654
+ async function PortfolioProjectDetailRoute({
655
+ params,
656
+ blockCtx
657
+ }) {
658
+ const slug = typeof params.slug === "string" ? params.slug : "";
659
+ if (!slug) notFound();
660
+ const result = await findDocuments("posts", {
661
+ where: { slug, status: "published" },
662
+ limit: 1
663
+ });
664
+ const doc = result.docs[0];
665
+ if (!doc) notFound();
666
+ const templateProps = {
667
+ doc,
668
+ blockCtx
669
+ };
670
+ return /* @__PURE__ */ jsx9(ProjectDetailTemplate, { ...templateProps });
671
+ }
672
+
673
+ // src/shell.tsx
674
+ import { jsx as jsx10 } from "react/jsx-runtime";
675
+ var ASPECT_VALUES2 = {
676
+ square: "1 / 1",
677
+ portrait: "3 / 4",
678
+ landscape: "4 / 3",
679
+ golden: "1 / 1.618"
680
+ };
681
+ async function PortfolioShell({ children }) {
682
+ const settings = await resolvePortfolioSettings();
683
+ const aspect = ASPECT_VALUES2[settings.cardAspect];
684
+ const styleVars = {
685
+ "--np-portfolio-card-aspect": aspect
686
+ };
687
+ if (settings.accentColor) {
688
+ styleVars["--np-color-primary"] = settings.accentColor;
689
+ }
690
+ return /* @__PURE__ */ jsx10(
691
+ "div",
692
+ {
693
+ className: "np-portfolio",
694
+ "data-hover-style": settings.hoverStyle,
695
+ style: styleVars,
696
+ children
697
+ }
698
+ );
699
+ }
700
+
701
+ // src/styles.ts
702
+ var portfolioCss = `
703
+ .np-portfolio {
704
+ background: var(--np-color-background);
705
+ color: var(--np-color-foreground);
706
+ min-height: 100vh;
707
+ font-family: var(--np-font-body, "Inter", system-ui, sans-serif);
708
+ }
709
+ .np-portfolio a { color: inherit; }
710
+ .np-portfolio ::selection {
711
+ background: var(--np-color-primary);
712
+ color: var(--np-color-primary-foreground);
713
+ }
714
+
715
+ /* ----------------------------------------------------------------
716
+ * Header
717
+ * --------------------------------------------------------------- */
718
+ .np-portfolio-header {
719
+ background: color-mix(in oklab, var(--np-color-background) 85%, transparent);
720
+ backdrop-filter: blur(8px);
721
+ -webkit-backdrop-filter: blur(8px);
722
+ border-bottom: 1px solid color-mix(in oklab, var(--np-color-foreground) 8%, transparent);
723
+ display: flex;
724
+ align-items: center;
725
+ justify-content: space-between;
726
+ padding: 1rem 2rem;
727
+ position: sticky;
728
+ top: 0;
729
+ z-index: 30;
730
+ gap: 1rem;
731
+ }
732
+ .np-portfolio-logo {
733
+ font-weight: 600;
734
+ letter-spacing: 0.02em;
735
+ text-decoration: none;
736
+ font-size: 0.95rem;
737
+ }
738
+ .np-portfolio-nav {
739
+ list-style: none;
740
+ margin: 0;
741
+ padding: 0;
742
+ display: flex;
743
+ gap: 1.5rem;
744
+ font-size: 0.875rem;
745
+ }
746
+ .np-portfolio-nav a {
747
+ text-decoration: none;
748
+ opacity: 0.75;
749
+ transition: opacity 0.15s ease;
750
+ }
751
+ .np-portfolio-nav a:hover { opacity: 1; }
752
+ .np-portfolio-nav-item {
753
+ position: relative;
754
+ }
755
+ .np-portfolio-subnav {
756
+ position: absolute;
757
+ top: 100%;
758
+ left: 0;
759
+ display: none;
760
+ min-width: 11rem;
761
+ padding: 0.5rem 0;
762
+ margin: 0;
763
+ list-style: none;
764
+ background: var(--np-color-card, #fff);
765
+ border: 1px solid var(--np-color-border, #e5e7eb);
766
+ border-radius: var(--np-radius-md, 0.5rem);
767
+ box-shadow: 0 4px 16px -8px rgba(0, 0, 0, 0.08);
768
+ z-index: 10;
769
+ }
770
+ .np-portfolio-nav-item:hover > .np-portfolio-subnav,
771
+ .np-portfolio-nav-item:focus-within > .np-portfolio-subnav {
772
+ display: block;
773
+ }
774
+ .np-portfolio-subnav a {
775
+ display: block;
776
+ padding: 0.4rem 1rem;
777
+ font-size: 0.875rem;
778
+ }
779
+ .np-portfolio-mobile-subnav {
780
+ list-style: none;
781
+ margin: 0;
782
+ padding-left: 1.25rem;
783
+ }
784
+
785
+ /* Mobile drawer */
786
+ .np-portfolio-nav-toggle {
787
+ display: none;
788
+ align-items: center;
789
+ justify-content: center;
790
+ padding: 0.4rem 0.85rem;
791
+ border: 1px solid color-mix(in oklab, var(--np-color-foreground) 20%, transparent);
792
+ border-radius: 999px;
793
+ background: transparent;
794
+ color: inherit;
795
+ font: inherit;
796
+ font-size: 0.75rem;
797
+ letter-spacing: 0.06em;
798
+ cursor: pointer;
799
+ }
800
+ .np-portfolio-nav-toggle:hover {
801
+ border-color: color-mix(in oklab, var(--np-color-foreground) 50%, transparent);
802
+ }
803
+ .np-portfolio-nav-drawer {
804
+ position: fixed;
805
+ inset: 0;
806
+ background: color-mix(in oklab, var(--np-color-background) 95%, transparent);
807
+ backdrop-filter: blur(12px);
808
+ -webkit-backdrop-filter: blur(12px);
809
+ z-index: 50;
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ opacity: 0;
814
+ visibility: hidden;
815
+ transition: opacity 0.25s ease, visibility 0.25s ease;
816
+ }
817
+ .np-portfolio-nav-drawer[data-open="true"] {
818
+ opacity: 1;
819
+ visibility: visible;
820
+ }
821
+ .np-portfolio-nav-drawer-list {
822
+ list-style: none;
823
+ margin: 0;
824
+ padding: 0;
825
+ display: flex;
826
+ flex-direction: column;
827
+ gap: 1.5rem;
828
+ text-align: center;
829
+ font-size: clamp(1.4rem, 3vw, 2rem);
830
+ font-weight: 500;
831
+ letter-spacing: -0.01em;
832
+ }
833
+ .np-portfolio-nav-drawer-list a {
834
+ color: inherit;
835
+ text-decoration: none;
836
+ opacity: 0.85;
837
+ transition: opacity 0.15s ease;
838
+ }
839
+ .np-portfolio-nav-drawer-list a:hover { opacity: 1; }
840
+
841
+ @media (max-width: 720px) {
842
+ .np-portfolio-nav-desktop { display: none; }
843
+ .np-portfolio-nav-toggle { display: inline-flex; }
844
+ }
845
+ @media (min-width: 721px) {
846
+ .np-portfolio-nav-drawer { display: none; }
847
+ }
848
+
849
+ /* ----------------------------------------------------------------
850
+ * Footer
851
+ * --------------------------------------------------------------- */
852
+ .np-portfolio-footer {
853
+ border-top: 1px solid color-mix(in oklab, var(--np-color-foreground) 8%, transparent);
854
+ margin-top: 6rem;
855
+ background: transparent;
856
+ text-align: center;
857
+ }
858
+ .np-portfolio-footer-inner {
859
+ max-width: 960px;
860
+ margin: 0 auto;
861
+ padding: 2.5rem 1.5rem;
862
+ display: flex;
863
+ flex-direction: column;
864
+ gap: 1rem;
865
+ align-items: center;
866
+ }
867
+ .np-portfolio-footer-contact { font-size: 1.05rem; }
868
+ .np-portfolio-footer-email {
869
+ text-decoration: none;
870
+ letter-spacing: 0.02em;
871
+ border-bottom: 1px solid color-mix(in oklab, var(--np-color-foreground) 40%, transparent);
872
+ padding-bottom: 0.15rem;
873
+ }
874
+ .np-portfolio-footer-email:hover {
875
+ border-bottom-color: color-mix(in oklab, var(--np-color-foreground) 85%, transparent);
876
+ }
877
+ .np-portfolio-footer-social {
878
+ list-style: none;
879
+ margin: 0;
880
+ padding: 0;
881
+ display: flex;
882
+ flex-wrap: wrap;
883
+ justify-content: center;
884
+ gap: 1.5rem;
885
+ font-size: 0.85rem;
886
+ text-transform: uppercase;
887
+ letter-spacing: 0.16em;
888
+ }
889
+ .np-portfolio-footer-social a {
890
+ text-decoration: none;
891
+ opacity: 0.65;
892
+ transition: opacity 0.15s ease;
893
+ }
894
+ .np-portfolio-footer-social a:hover { opacity: 1; }
895
+ .np-portfolio-footer-mark {
896
+ margin: 0;
897
+ font-size: 0.78rem;
898
+ opacity: 0.5;
899
+ letter-spacing: 0.06em;
900
+ }
901
+
902
+ /* ----------------------------------------------------------------
903
+ * Page templates
904
+ * --------------------------------------------------------------- */
905
+ .np-portfolio-page {
906
+ max-width: 720px;
907
+ margin: 0 auto;
908
+ padding: 4rem 1.5rem;
909
+ line-height: 1.7;
910
+ }
911
+ .np-portfolio-page h1,
912
+ .np-portfolio-page h2,
913
+ .np-portfolio-page h3 { letter-spacing: -0.01em; }
914
+
915
+ .np-portfolio-gallery {
916
+ max-width: 1280px;
917
+ margin: 0 auto;
918
+ padding: 3rem 1.5rem 4rem;
919
+ }
920
+ .np-portfolio-gallery > h1 {
921
+ text-align: center;
922
+ font-size: clamp(2rem, 4vw, 3.5rem);
923
+ margin: 0 0 2.5rem;
924
+ letter-spacing: -0.02em;
925
+ }
926
+ .np-portfolio-gallery-grid {
927
+ display: grid;
928
+ grid-template-columns: 1fr;
929
+ gap: 1.5rem;
930
+ }
931
+ @media (min-width: 720px) {
932
+ .np-portfolio-gallery-grid { grid-template-columns: 1fr 1fr; }
933
+ }
934
+ .np-portfolio-gallery-grid img {
935
+ width: 100%;
936
+ height: auto;
937
+ display: block;
938
+ border-radius: 8px;
939
+ }
940
+
941
+ /* ----------------------------------------------------------------
942
+ * Project index (grid of cards)
943
+ * --------------------------------------------------------------- */
944
+ .np-portfolio-index {
945
+ max-width: 1320px;
946
+ margin: 0 auto;
947
+ padding: 3.5rem 1.5rem 4rem;
948
+ }
949
+ .np-portfolio-index-header {
950
+ text-align: center;
951
+ margin-bottom: 3rem;
952
+ }
953
+ .np-portfolio-index-header h1 {
954
+ font-size: clamp(2.25rem, 4vw, 3rem);
955
+ letter-spacing: -0.02em;
956
+ margin: 0 0 0.65rem;
957
+ font-weight: 600;
958
+ }
959
+ .np-portfolio-index-header p {
960
+ margin: 0 auto;
961
+ max-width: 38rem;
962
+ opacity: 0.75;
963
+ line-height: 1.6;
964
+ }
965
+ .np-portfolio-index-empty {
966
+ text-align: center;
967
+ padding: 4rem 1.5rem;
968
+ opacity: 0.6;
969
+ }
970
+ .np-portfolio-index-grid {
971
+ /* Phase F.9.1-A \u2014 operator's settings.gridColumns
972
+ * sets --np-portfolio-grid-cols on this element via the
973
+ * project-index template. Mobile clamps to 1 column
974
+ * regardless; tablet caps at min(2, --np-portfolio-grid-cols);
975
+ * desktop honors the operator's choice up to 6. Operator
976
+ * stays in control without breaking responsive design.
977
+ */
978
+ --np-portfolio-grid-cols: 3;
979
+ --np-portfolio-grid-gutter: 1.5rem;
980
+ display: grid;
981
+ grid-template-columns: 1fr;
982
+ gap: var(--np-portfolio-grid-gutter);
983
+ }
984
+ @media (min-width: 640px) {
985
+ .np-portfolio-index-grid {
986
+ grid-template-columns: repeat(min(2, var(--np-portfolio-grid-cols, 3)), 1fr);
987
+ }
988
+ }
989
+ @media (min-width: 1024px) {
990
+ .np-portfolio-index-grid {
991
+ grid-template-columns: repeat(var(--np-portfolio-grid-cols, 3), 1fr);
992
+ }
993
+ }
994
+
995
+ /* ----------------------------------------------------------------
996
+ * Project card
997
+ * --------------------------------------------------------------- */
998
+ .np-portfolio-project-card {
999
+ display: block;
1000
+ text-decoration: none;
1001
+ color: inherit;
1002
+ position: relative;
1003
+ overflow: hidden;
1004
+ border-radius: 4px;
1005
+ background: var(--np-color-card);
1006
+ }
1007
+ .np-portfolio-project-cover {
1008
+ margin: 0;
1009
+ position: relative;
1010
+ /* Phase F.9.1-B \u2014 operator-tunable aspect via shell-set
1011
+ * --np-portfolio-card-aspect (square / portrait / landscape /
1012
+ * golden). Falls back to 4/3 when the variable isn't set. */
1013
+ aspect-ratio: var(--np-portfolio-card-aspect, 4 / 3);
1014
+ overflow: hidden;
1015
+ }
1016
+ .np-portfolio-project-cover img {
1017
+ width: 100%;
1018
+ height: 100%;
1019
+ object-fit: cover;
1020
+ display: block;
1021
+ transition: transform 0.5s ease, filter 0.4s ease;
1022
+ }
1023
+ /* Phase F.9.1-B \u2014 hoverStyle variants. Selected via the shell's
1024
+ * data-hover-style attribute. Default ("fade") = caption fades in
1025
+ * + image scale; the others swap the image effect.
1026
+ * - scale: only the image zooms (caption stays subtle)
1027
+ * - slide: image stays put; caption slides up from below
1028
+ * - lift: card lifts with shadow; image static
1029
+ */
1030
+ .np-portfolio[data-hover-style="fade"] .np-portfolio-project-card:hover .np-portfolio-project-cover img,
1031
+ .np-portfolio[data-hover-style="scale"] .np-portfolio-project-card:hover .np-portfolio-project-cover img {
1032
+ transform: scale(1.04);
1033
+ }
1034
+ .np-portfolio[data-hover-style="lift"] .np-portfolio-project-card {
1035
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
1036
+ }
1037
+ .np-portfolio[data-hover-style="lift"] .np-portfolio-project-card:hover {
1038
+ transform: translateY(-4px);
1039
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
1040
+ }
1041
+ .np-portfolio[data-hover-style="slide"] .np-portfolio-project-caption {
1042
+ /* Slide-up reveal \u2014 caption starts further below + opacity 0 */
1043
+ transform: translateY(24px);
1044
+ }
1045
+ .np-portfolio-project-placeholder {
1046
+ display: block;
1047
+ width: 100%;
1048
+ height: 100%;
1049
+ background: linear-gradient(
1050
+ 135deg,
1051
+ var(--np-color-muted) 0%,
1052
+ var(--np-color-accent) 100%
1053
+ );
1054
+ }
1055
+ .np-portfolio-project-caption {
1056
+ position: absolute;
1057
+ inset: auto 0 0 0;
1058
+ padding: 1rem 1.25rem;
1059
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
1060
+ display: flex;
1061
+ flex-direction: column;
1062
+ gap: 0.2rem;
1063
+ opacity: 0;
1064
+ transform: translateY(8px);
1065
+ transition: opacity 0.25s ease, transform 0.25s ease;
1066
+ }
1067
+ .np-portfolio-project-card:hover .np-portfolio-project-caption {
1068
+ opacity: 1;
1069
+ transform: translateY(0);
1070
+ }
1071
+ .np-portfolio-project-title {
1072
+ font-weight: 600;
1073
+ letter-spacing: 0.01em;
1074
+ font-size: 1rem;
1075
+ }
1076
+ .np-portfolio-project-category {
1077
+ font-size: 0.72rem;
1078
+ text-transform: uppercase;
1079
+ letter-spacing: 0.16em;
1080
+ opacity: 0.8;
1081
+ }
1082
+
1083
+ /* ----------------------------------------------------------------
1084
+ * Project detail
1085
+ * --------------------------------------------------------------- */
1086
+ .np-portfolio-project-detail {
1087
+ margin: 0;
1088
+ padding: 0 0 4rem;
1089
+ }
1090
+ .np-portfolio-project-hero {
1091
+ margin: 0;
1092
+ width: 100%;
1093
+ aspect-ratio: 21 / 9;
1094
+ overflow: hidden;
1095
+ background: var(--np-color-card);
1096
+ }
1097
+ .np-portfolio-project-hero img {
1098
+ width: 100%;
1099
+ height: 100%;
1100
+ object-fit: cover;
1101
+ display: block;
1102
+ }
1103
+ .np-portfolio-project-header {
1104
+ max-width: 760px;
1105
+ margin: 0 auto;
1106
+ padding: 3rem 1.5rem 2rem;
1107
+ text-align: center;
1108
+ }
1109
+ .np-portfolio-project-header h1 {
1110
+ font-size: clamp(2rem, 4vw, 3rem);
1111
+ letter-spacing: -0.02em;
1112
+ margin: 0 0 0.85rem;
1113
+ font-weight: 600;
1114
+ }
1115
+ .np-portfolio-project-excerpt {
1116
+ margin: 0 auto;
1117
+ max-width: 38rem;
1118
+ opacity: 0.75;
1119
+ font-size: 1.075rem;
1120
+ line-height: 1.55;
1121
+ }
1122
+ .np-portfolio-project-meta {
1123
+ margin: 2rem auto 0;
1124
+ display: grid;
1125
+ grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
1126
+ justify-content: center;
1127
+ gap: 0 2rem;
1128
+ font-size: 0.85rem;
1129
+ text-align: start;
1130
+ }
1131
+ .np-portfolio-project-meta dt {
1132
+ text-transform: uppercase;
1133
+ letter-spacing: 0.16em;
1134
+ font-size: 0.7rem;
1135
+ opacity: 0.55;
1136
+ margin-bottom: 0.2rem;
1137
+ }
1138
+ .np-portfolio-project-meta dd {
1139
+ margin: 0 0 0.75rem;
1140
+ font-weight: 500;
1141
+ }
1142
+ .np-portfolio-project-body {
1143
+ max-width: 720px;
1144
+ margin: 0 auto;
1145
+ padding: 0 1.5rem;
1146
+ font-size: 1.05rem;
1147
+ line-height: 1.7;
1148
+ opacity: 0.92;
1149
+ }
1150
+ .np-portfolio-project-body img {
1151
+ max-width: 100%;
1152
+ height: auto;
1153
+ border-radius: 6px;
1154
+ margin: 1.5rem 0;
1155
+ }
1156
+
1157
+ /* Re-cast the .np-page baseline so links pick up the theme's
1158
+ primary token. Dark theme: primary is light-on-dark, so the
1159
+ link reads correctly. */
1160
+ .np-portfolio .np-page a {
1161
+ color: var(--np-color-primary);
1162
+ }
1163
+
1164
+ /* M.* member surface \u2014 narrow auth-form column under the
1165
+ masthead. The portfolio's public layout is image-led wide;
1166
+ stretching a login form across that would look weird. */
1167
+ .np-portfolio-members {
1168
+ display: flex;
1169
+ justify-content: center;
1170
+ min-height: 60vh;
1171
+ padding: 3rem 1.5rem;
1172
+ }
1173
+ .np-portfolio-members-column {
1174
+ width: 100%;
1175
+ max-width: 420px;
1176
+ }
1177
+
1178
+ /* Member form token overrides \u2014 portfolio's minimal aesthetic:
1179
+ sharp corners, hairline borders, theme primary on focus. */
1180
+ .np-portfolio .np-members-form {
1181
+ --np-member-form-input-bg: transparent;
1182
+ --np-member-form-input-border: var(--np-color-border);
1183
+ --np-member-form-input-border-focus: var(--np-color-primary);
1184
+ --np-member-form-input-radius: 0.25rem;
1185
+ --np-member-form-button-radius: 0.25rem;
1186
+ }
1187
+ .np-portfolio .np-members-form .np-form-label {
1188
+ font-size: 0.75rem;
1189
+ text-transform: uppercase;
1190
+ letter-spacing: 0.12em;
1191
+ }
1192
+ `.trim();
1193
+
1194
+ // src/templates/page-default.tsx
1195
+ import { renderBlocks as renderBlocks2 } from "@nexpress/blocks";
1196
+ import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
1197
+ function PageDefaultTemplate({ doc, blockCtx }) {
1198
+ const blocks = doc.blocks;
1199
+ const title = doc.title;
1200
+ return /* @__PURE__ */ jsxs9("article", { className: "np-page np-portfolio-page", children: [
1201
+ title ? /* @__PURE__ */ jsx11("h1", { children: title }) : null,
1202
+ blocks ? renderBlocks2(blocks, { ctx: blockCtx }) : null
1203
+ ] });
1204
+ }
1205
+
1206
+ // src/templates/page-gallery.tsx
1207
+ import { renderBlocks as renderBlocks3 } from "@nexpress/blocks";
1208
+ import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
1209
+ function PageGalleryTemplate({ doc, blockCtx }) {
1210
+ const blocks = doc.blocks;
1211
+ const title = doc.title;
1212
+ return /* @__PURE__ */ jsxs10("section", { className: "np-portfolio-gallery", children: [
1213
+ title ? /* @__PURE__ */ jsx12("h1", { children: title }) : null,
1214
+ /* @__PURE__ */ jsx12("div", { className: "np-portfolio-gallery-grid", children: blocks ? renderBlocks3(blocks, { ctx: blockCtx }) : null })
1215
+ ] });
1216
+ }
1217
+
1218
+ // src/templates/project-index.tsx
1219
+ import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1220
+ async function ProjectIndexTemplate({
1221
+ doc
1222
+ }) {
1223
+ const data = doc;
1224
+ const settings = await resolvePortfolioSettings();
1225
+ const heading = data.heading ?? "Selected work";
1226
+ const intro = data.intro;
1227
+ const docs = data.docs ?? [];
1228
+ const gridStyle = {
1229
+ "--np-portfolio-grid-cols": settings.gridColumns,
1230
+ "--np-portfolio-grid-gutter": `${settings.galleryGutter}px`
1231
+ };
1232
+ return /* @__PURE__ */ jsxs11("section", { className: "np-portfolio-index", children: [
1233
+ /* @__PURE__ */ jsxs11("header", { className: "np-portfolio-index-header", children: [
1234
+ /* @__PURE__ */ jsx13("h1", { children: heading }),
1235
+ intro ? /* @__PURE__ */ jsx13("p", { children: intro }) : null
1236
+ ] }),
1237
+ docs.length === 0 ? /* @__PURE__ */ jsx13("p", { className: "np-portfolio-index-empty", children: "Nothing on display yet. Add projects from the admin to fill the grid." }) : /* @__PURE__ */ jsx13("div", { className: "np-portfolio-index-grid", style: gridStyle, children: docs.map((project) => /* @__PURE__ */ jsx13(
1238
+ PortfolioProjectCard,
1239
+ {
1240
+ doc: project
1241
+ },
1242
+ project.id ?? project.slug ?? project.title
1243
+ )) })
1244
+ ] });
1245
+ }
1246
+
1247
+ // src/index.ts
1248
+ var portfolioTheme = defineTheme({
1249
+ manifest: {
1250
+ id: "portfolio",
1251
+ name: "Portfolio",
1252
+ version: "0.1.0",
1253
+ description: "Image-led dark theme for studios and designers. Hero-led project detail template, archive grid, gallery and centered page templates.",
1254
+ author: { name: "NexPress" },
1255
+ nexpress: { minVersion: "0.1.0" },
1256
+ // Phase F.1 — declared data-shape requirements. The CLI
1257
+ // (`pnpm nexpress theme:install @nexpress/theme-portfolio`)
1258
+ // patches operator collections to satisfy these.
1259
+ requires: {
1260
+ collections: {
1261
+ posts: {
1262
+ fields: {
1263
+ heroImage: { type: "upload" },
1264
+ client: { type: "text", hard: false },
1265
+ year: { type: "number", hard: false },
1266
+ role: { type: "text", hard: false }
1267
+ }
1268
+ }
1269
+ }
1270
+ },
1271
+ // Phase F.3 — operator-tunable settings. Stresses the
1272
+ // auto-form on deep schema (10 fields, range-constrained
1273
+ // numbers, color regex, nested array of objects).
1274
+ settingsSchema: portfolioSettingsSchema
1275
+ },
1276
+ impl: {
1277
+ shell: PortfolioShell,
1278
+ slots: {
1279
+ header: PortfolioHeader,
1280
+ footer: PortfolioFooter
1281
+ },
1282
+ // Dark palette is now token-driven — previously the dark
1283
+ // surface was hardcoded as `#0b0b0c` in `styles.ts`, so admin
1284
+ // overrides couldn't reach it. Tokens here flip background +
1285
+ // foreground for the whole shell; `styles.ts` reads them via
1286
+ // `var(--np-color-*)` so a single token change reflows the
1287
+ // entire theme. Light variant: override these in the admin's
1288
+ // theme settings tab — no fork required.
1289
+ tokens: {
1290
+ colors: {
1291
+ primary: "oklch(0.985 0.001 106)",
1292
+ primaryForeground: "oklch(0.145 0.005 285)",
1293
+ background: "oklch(0.16 0.005 285)",
1294
+ foreground: "oklch(0.91 0.003 286)",
1295
+ muted: "oklch(0.22 0.006 286)",
1296
+ mutedForeground: "oklch(0.66 0.005 286)",
1297
+ border: "oklch(0.28 0.008 286)",
1298
+ card: "oklch(0.20 0.006 286)",
1299
+ cardForeground: "oklch(0.91 0.003 286)",
1300
+ accent: "oklch(0.32 0.012 286)",
1301
+ accentForeground: "oklch(0.985 0.001 106)"
1302
+ },
1303
+ typography: {
1304
+ fontHeading: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
1305
+ fontBody: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif'
1306
+ }
1307
+ },
1308
+ css: portfolioCss,
1309
+ templates: {
1310
+ pages: {
1311
+ default: {
1312
+ label: "Default",
1313
+ description: "Centered text column on dark background.",
1314
+ component: PageDefaultTemplate
1315
+ },
1316
+ gallery: {
1317
+ label: "Gallery",
1318
+ description: "Two-column block grid for image-led project pages and case studies.",
1319
+ component: PageGalleryTemplate
1320
+ }
1321
+ },
1322
+ posts: {
1323
+ detail: {
1324
+ label: "Project detail",
1325
+ description: "Hero image, centered title and excerpt, role / year / client meta strip, then the body blocks.",
1326
+ component: ProjectDetailTemplate
1327
+ },
1328
+ index: {
1329
+ label: "Project index",
1330
+ description: "Archive grid of square project cards with hover-fade captions.",
1331
+ component: ProjectIndexTemplate
1332
+ }
1333
+ }
1334
+ },
1335
+ // F.2 — theme routes. `/work/:slug` dispatches a posts row
1336
+ // through `ProjectDetailTemplate` (#613). Without this, the
1337
+ // `/work/<slug>` URLs `PortfolioProjectCard` emits would
1338
+ // 404 — the framework catch-all only resolves `pages` rows
1339
+ // by URL, so case studies (`posts` collection) need a theme
1340
+ // route to be reachable.
1341
+ routes: [
1342
+ { pattern: "/work/:slug", component: PortfolioProjectDetailRoute }
1343
+ ],
1344
+ // Phase F.4 — portfolio-shipped block types.
1345
+ blocks: portfolioBlocks,
1346
+ // Phase F.6 — declared nav locations.
1347
+ navLocations: {
1348
+ primary: {
1349
+ label: "Primary nav",
1350
+ description: "Top nav links (Work / About / Contact).",
1351
+ maxItems: 5
1352
+ },
1353
+ footerSocial: {
1354
+ label: "Footer social links",
1355
+ description: "Social profile links shown in the footer.",
1356
+ maxItems: 6
1357
+ }
1358
+ },
1359
+ // Phase F.7 — error chrome.
1360
+ notFound: PortfolioNotFound,
1361
+ // M.* adoption (2026-05-11). Portfolio gains purpose-built
1362
+ // member chrome: narrow column wrapping the auth forms,
1363
+ // tonally matched 404 + error pages. The fallback chain in
1364
+ // `<ShellWrap surface="member">` would have walked back to
1365
+ // `impl.shell` + the public slots, which would have stretched
1366
+ // a 320-wide login form across the image-led wide layout.
1367
+ // - `shell`: PortfolioMembersShell (narrow column, same
1368
+ // header/footer chrome so a masthead bump cascades).
1369
+ // - `notFound`: PortfolioMembersNotFound (stale-auth-link
1370
+ // framing with /members/login CTA).
1371
+ // - `error`: forward-compat type marker; the actual render
1372
+ // goes through `./components/members-error`'s client
1373
+ // subpath, lazy-imported by
1374
+ // `apps/web/src/app/(member)/error.tsx`'s registry
1375
+ // (F.7.1 delegation — Next mandates `error.tsx` is "use
1376
+ // client").
1377
+ members: {
1378
+ shell: PortfolioMembersShell,
1379
+ notFound: PortfolioMembersNotFound
1380
+ }
1381
+ }
1382
+ });
1383
+ export {
1384
+ PortfolioFooter,
1385
+ PortfolioHeader,
1386
+ PortfolioMembersNotFound,
1387
+ PortfolioMembersShell,
1388
+ PortfolioMobileNav2 as PortfolioMobileNav,
1389
+ PortfolioNotFound,
1390
+ PortfolioProjectCard,
1391
+ PortfolioShell,
1392
+ portfolioCss,
1393
+ portfolioSettingsSchema,
1394
+ portfolioTheme
1395
+ };
1396
+ //# sourceMappingURL=index.js.map