# app.zeens.portfolio

> Published by [zeens.app](https://lexicon.garden/identity/did:plc:pwpuqwge6g7xrjmfbw73uycw)

✓ This is the authoritative definition for this NSID.

## Links

- [View on Lexicon Garden](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.portfolio)
- [Documentation](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.portfolio/docs)
- [Examples](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.portfolio/examples)

## Definitions

### `app.zeens.portfolio#aboutLink`

**Type**: `object`

One titled external link on the About page. Readers MUST re-validate `url` and accept only https: the record lives on the owner's PDS and any client can write it, so the value reaches an href untrusted. Writers upgrade a typed http:// to https:// rather than storing insecure links.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `title` | `string` | Yes | Link label, e.g. 'Buy prints'. |
| `url` | `string` (uri) | Yes | Destination URL. https only. |

### `app.zeens.portfolio#aboutPage`

**Type**: `object`

The portfolio's About page. Plain text, like #homePage. The owner's portrait is NOT stored here: `useProfileAvatar` is a flag and the avatar is resolved from the profile at render time, so it tracks their Zeens avatar with no copy to go stale.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `body` | `string` | No | About text, rendered with preserved line breaks. |
| `enabled` | `boolean` | Yes | Whether the About page exists and appears in the rail. |
| `heading` | `string` | No | Page heading, also used as the rail's label for it. Defaults to 'About' when absent. |
| `links` | `array` | No | Titled external links shown as one horizontal row above the About photo grid (e.g. 'Buy prints', 'My Instagram'). Distinct from the profile socials in the rail: those are platform accounts, these are anything the owner wants to point at. |
| `photos` | `array` | No | Photos chosen from the owner's own posts, in display order. Rendered as a grid below the About text: one photo full width, two side by side, three or more in three columns (six fills a 3x2 grid). Only the first image of a multi-image post is shown. |
| `useProfileAvatar` | `boolean` | No | Show the owner's live profile avatar as the portrait. Opt-in: absent means false, so a portrait never appears on the public page unless the owner asked for it. |

### `app.zeens.portfolio#aboutPhoto`

**Type**: `object`

A reference to one of the owner's own posts, used wherever a block stores picked photos — the About page, page heroes, and Featured Work. Named for its first use; kept under that name because renaming a def would orphan the records already pointing at it.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `cid` | `string` | Yes | CID of the referenced post at pick time. |
| `uri` | `string` (at-uri) | Yes | AT-URI of an app.zeens.post / app.bsky.feed.post record on the owner's repo. |

### `app.zeens.portfolio#collectionRef`

**Type**: `object`

A reference to one of the user's collections, in menu order.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `collection` | `string` (at-uri) | Yes | AT-URI of an app.zeens.collection record on the owner's repo. |

### `app.zeens.portfolio#featuredBlock`

**Type**: `object`

Featured work: photos picked directly rather than through a collection. The heading is the owner's own — 'Featured Work' is one gallery's word for it and 'Selected' or 'Latest' is another's.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `photos` | `array` | Yes | Photos chosen from the owner's own posts, in display order. |
| `title` | `string` | No | Section heading. ABSENT MEANS 'Featured Work' — the default lives in the render path, so it is not frozen into every record. |

### `app.zeens.portfolio#heroBlock`

**Type**: `object`

A page hero: the owner's photos across the top of a page. One def serves all three arrangements because they are the same data seen differently. A hero with no photos is dropped on write rather than stored empty — an absent hero falls back to the owner's most-liked photos, which is a better empty state than a blank band.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `mode` | `string` | Yes | 'single' = one full-width photo; 'carousel' = a swipeable run of them; 'grid' = all of them as a grid. Unknown/absent reads as 'single'. |
| `photos` | `array` | Yes | Photos chosen from the owner's own posts, in display order. Only the first image of a multi-image post is used. |

### `app.zeens.portfolio#homePage`

**Type**: `object`

Plain-text intro shown at the top of the portfolio home page. Deliberately NOT rich text: it renders into a public page, so a markup format would need sanitising and carry XSS surface. Mirrors how collections store their description.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `body` | `string` | No | Home page paragraph, rendered with preserved line breaks. |
| `heading` | `string` | No | Home page heading. When present it supplies the page's <h1>, and a home collection's own title renders as <h2> so the page keeps exactly one top-level heading. |

### `app.zeens.portfolio`

**Type**: `record`

Configuration for a user's public Portfolio site (e.g. alice.zeens.app). One per user, keyed 'self'. A portfolio is a curated list of the user's COLLECTIONS, displayed beautifully: it holds the subdomain, a theme + layout/columns/gap applied to every collection, the ordered list of collection AT-URIs to show, and an optional home collection. Collections own their own title/subtitle/description/photos/slug, which the portfolio reuses; the site header comes from the user's profile. Stored on the user's own PDS so the site is portable and user-owned; PocketBase only mirrors {subdomain -> did} for host routing + uniqueness. Rendered publicly only when `enabled` is true.

**Key**: `literal:self`

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `about` | `ref` → `#aboutPage` | No | Optional About page (alice.zeens.app/about). Absent, or present with enabled=false, means the portfolio has no About page and no About entry in its rail. |
| `app.zeens.v` | `integer` | No | Zeens marker so the firehose / lua filters can identify our records. |
| `appearance` | `string` | No | Light/dark appearance. 'auto' (default) follows the visitor's system preference; 'light'/'dark' force the theme's respective palette. |
| `collections` | `array` | No | Ordered list of the collections shown in the portfolio menu. Each entry references an app.zeens.collection record on the same repo. Array order is the menu order. |
| `columns` | `integer` | No | Column count for grid/masonry layouts. Allowed 2-4. Default 3. |
| `createdAt` | `string` (datetime) | Yes |  |
| `enabled` | `boolean` | Yes | Whether the public site is live. When false, the subdomain 404s even if reserved. |
| `featured` | `ref` → `#featuredBlock` | No | Hand-picked photos under a heading the owner chooses, shown on the home page below the hero. Independent of any collection. Absent means no section. |
| `gap` | `integer` | No | Gap in pixels between photos. Allowed 0, 4, 8, 16, 24, 32. Default 8. |
| `hero` | `ref` → `#heroBlock` | No | The home page's hero — the owner's own photos across the top. ABSENT DOES NOT MEAN NO HERO: the render path falls back to the owner's 6 most-liked photos as a square grid, so a portfolio has a hero without its owner picking one. Present means they chose the photos themselves. |
| `home` | `ref` → `#homePage` | No | Optional plain-text heading + paragraph shown ABOVE the home page's existing content (the collection card grid, or homeCollection's photos when one is set). Absent means the home page renders exactly as it did before this field existed, so no portfolio changes without its owner acting. |
| `homeCollection` | `string` (at-uri) | No | DEPRECATED. AT-URI of a collection rendered inline as the portfolio's home page. Superseded by the portfolio's own home page (see `home`): the editor no longer offers this, and new records should not set it. Still honoured on read so existing sites don't change appearance, and the editor lets an owner who has it set turn it off. |
| `layout` | `string` | No | Photo layout applied to every collection shown in the portfolio. 'single' = one full-width column; 'grid' = uniform columns; 'masonry' = variable-height columns. |
| `menu` | `ref` → `#menuSettings` | No | How the navigation menu renders. Absent means the default: the home page shows the owner's collections. |
| `palette` | `string` | No | Id of the chosen color palette (each has a light+dark variant, defined app-side, e.g. 'mono'). Independent of typography. Unknown/absent falls back to the default at render time. (Legacy records may carry a bundled 'theme' field instead; read paths migrate it.) |
| `socials` | `ref` → `#socialsVisibility` | No | Which of the owner's profile social links the public site shows. ABSENT MEANS SHOW ALL — every portfolio created before this field keeps showing every link with no migration. |
| `subdomain` | `string` | No | The chosen subdomain slug (the 'alice' in alice.zeens.app). Lowercase DNS-safe label; uniqueness + reserved-word validation happen at write time and are mirrored into the PB portfolio_slugs table. Absent until the user picks one. |
| `tagline` | `string` | No | One-line tagline for the site. Mirrored onto site.standard.publication.description, since the portfolio IS the publication — this is the line other standard.site readers show under the name. |
| `title` | `string` | No | Custom site title shown in the portfolio rail (free text, e.g. 'Ro', 'Alice Photography'). Falls back to the user's profile display name when absent. |
| `typography` | `string` | No | Id of the chosen typography pairing (font choice, defined app-side, e.g. 'space-grotesk'). Independent of palette. Unknown/absent falls back to the default at render time. |

### `app.zeens.portfolio#menuSettings`

**Type**: `object`

Navigation menu appearance. Its one property defaults to the behaviour that predates this def, so an absent `menu` never changes how a portfolio renders. Records written before the template took over navigation may still carry `icons` and `collections`; both axes were retired and readers ignore them.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `homeCollections` | `boolean` | No | Whether the home page shows the owner's collections. ABSENT MEANS TRUE. When on, the home page shows the 3 most recently updated as a row linking through to the Galleries page that lists the rest. Lives here rather than on #homePage because it concerns the collections, not the home text, and an all-empty homePage is dropped from the record. |

### `app.zeens.portfolio#socialsVisibility`

**Type**: `object`

Controls which profile social links the public portfolio shows. Filtering is applied server-side AFTER the Bluesky link is synthesised from the owner's handle, so hiding Bluesky works even though it is not stored on the profile record.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `mode` | `string` | Yes | 'all' (also the meaning of an absent socials field) shows every link; 'selected' shows only the platforms in `shown`; 'none' hides the block entirely, website included. |
| `shown` | `array` | No | Platform ids to show when mode is 'selected'. Ignored for other modes. |
| `website` | `boolean` | No | Whether the profile website link is shown. Absent means true. |

## Raw Schema

```json
{
  "$type": "com.atproto.lexicon.schema",
  "defs": {
    "aboutLink": {
      "description": "One titled external link on the About page. Readers MUST re-validate `url` and accept only https: the record lives on the owner's PDS and any client can write it, so the value reaches an href untrusted. Writers upgrade a typed http:// to https:// rather than storing insecure links.",
      "properties": {
        "title": {
          "description": "Link label, e.g. 'Buy prints'.",
          "maxLength": 60,
          "type": "string"
        },
        "url": {
          "description": "Destination URL. https only.",
          "format": "uri",
          "type": "string"
        }
      },
      "required": [
        "title",
        "url"
      ],
      "type": "object"
    },
    "aboutPage": {
      "description": "The portfolio's About page. Plain text, like #homePage. The owner's portrait is NOT stored here: `useProfileAvatar` is a flag and the avatar is resolved from the profile at render time, so it tracks their Zeens avatar with no copy to go stale.",
      "properties": {
        "body": {
          "description": "About text, rendered with preserved line breaks.",
          "maxLength": 4000,
          "type": "string"
        },
        "enabled": {
          "description": "Whether the About page exists and appears in the rail.",
          "type": "boolean"
        },
        "heading": {
          "description": "Page heading, also used as the rail's label for it. Defaults to 'About' when absent.",
          "maxLength": 120,
          "type": "string"
        },
        "links": {
          "description": "Titled external links shown as one horizontal row above the About photo grid (e.g. 'Buy prints', 'My Instagram'). Distinct from the profile socials in the rail: those are platform accounts, these are anything the owner wants to point at.",
          "items": {
            "ref": "#aboutLink",
            "type": "ref"
          },
          "maxLength": 8,
          "type": "array"
        },
        "photos": {
          "description": "Photos chosen from the owner's own posts, in display order. Rendered as a grid below the About text: one photo full width, two side by side, three or more in three columns (six fills a 3x2 grid). Only the first image of a multi-image post is shown.",
          "items": {
            "ref": "#aboutPhoto",
            "type": "ref"
          },
          "maxLength": 6,
          "type": "array"
        },
        "useProfileAvatar": {
          "description": "Show the owner's live profile avatar as the portrait. Opt-in: absent means false, so a portrait never appears on the public page unless the owner asked for it.",
          "type": "boolean"
        }
      },
      "required": [
        "enabled"
      ],
      "type": "object"
    },
    "aboutPhoto": {
      "description": "A reference to one of the owner's own posts, used wherever a block stores picked photos — the About page, page heroes, and Featured Work. Named for its first use; kept under that name because renaming a def would orphan the records already pointing at it.",
      "properties": {
        "cid": {
          "description": "CID of the referenced post at pick time.",
          "type": "string"
        },
        "uri": {
          "description": "AT-URI of an app.zeens.post / app.bsky.feed.post record on the owner's repo.",
          "format": "at-uri",
          "type": "string"
        }
      },
      "required": [
        "uri",
        "cid"
      ],
      "type": "object"
    },
    "collectionRef": {
      "description": "A reference to one of the user's collections, in menu order.",
      "properties": {
        "collection": {
          "description": "AT-URI of an app.zeens.collection record on the owner's repo.",
          "format": "at-uri",
          "type": "string"
        }
      },
      "required": [
        "collection"
      ],
      "type": "object"
    },
    "featuredBlock": {
      "description": "Featured work: photos picked directly rather than through a collection. The heading is the owner's own — 'Featured Work' is one gallery's word for it and 'Selected' or 'Latest' is another's.",
      "properties": {
        "photos": {
          "description": "Photos chosen from the owner's own posts, in display order.",
          "items": {
            "ref": "#aboutPhoto",
            "type": "ref"
          },
          "maxLength": 12,
          "type": "array"
        },
        "title": {
          "description": "Section heading. ABSENT MEANS 'Featured Work' — the default lives in the render path, so it is not frozen into every record.",
          "maxLength": 60,
          "type": "string"
        }
      },
      "required": [
        "photos"
      ],
      "type": "object"
    },
    "heroBlock": {
      "description": "A page hero: the owner's photos across the top of a page. One def serves all three arrangements because they are the same data seen differently. A hero with no photos is dropped on write rather than stored empty — an absent hero falls back to the owner's most-liked photos, which is a better empty state than a blank band.",
      "properties": {
        "mode": {
          "description": "'single' = one full-width photo; 'carousel' = a swipeable run of them; 'grid' = all of them as a grid. Unknown/absent reads as 'single'.",
          "knownValues": [
            "single",
            "carousel",
            "grid"
          ],
          "type": "string"
        },
        "photos": {
          "description": "Photos chosen from the owner's own posts, in display order. Only the first image of a multi-image post is used.",
          "items": {
            "ref": "#aboutPhoto",
            "type": "ref"
          },
          "maxLength": 6,
          "type": "array"
        }
      },
      "required": [
        "mode",
        "photos"
      ],
      "type": "object"
    },
    "homePage": {
      "description": "Plain-text intro shown at the top of the portfolio home page. Deliberately NOT rich text: it renders into a public page, so a markup format would need sanitising and carry XSS surface. Mirrors how collections store their description.",
      "properties": {
        "body": {
          "description": "Home page paragraph, rendered with preserved line breaks.",
          "maxLength": 2000,
          "type": "string"
        },
        "heading": {
          "description": "Home page heading. When present it supplies the page's <h1>, and a home collection's own title renders as <h2> so the page keeps exactly one top-level heading.",
          "maxLength": 120,
          "type": "string"
        }
      },
      "type": "object"
    },
    "main": {
      "description": "Configuration for a user's public Portfolio site (e.g. alice.zeens.app). One per user, keyed 'self'. A portfolio is a curated list of the user's COLLECTIONS, displayed beautifully: it holds the subdomain, a theme + layout/columns/gap applied to every collection, the ordered list of collection AT-URIs to show, and an optional home collection. Collections own their own title/subtitle/description/photos/slug, which the portfolio reuses; the site header comes from the user's profile. Stored on the user's own PDS so the site is portable and user-owned; PocketBase only mirrors {subdomain -> did} for host routing + uniqueness. Rendered publicly only when `enabled` is true.",
      "key": "literal:self",
      "record": {
        "properties": {
          "about": {
            "description": "Optional About page (alice.zeens.app/about). Absent, or present with enabled=false, means the portfolio has no About page and no About entry in its rail.",
            "ref": "#aboutPage",
            "type": "ref"
          },
          "app.zeens.v": {
            "description": "Zeens marker so the firehose / lua filters can identify our records.",
            "type": "integer"
          },
          "appearance": {
            "description": "Light/dark appearance. 'auto' (default) follows the visitor's system preference; 'light'/'dark' force the theme's respective palette.",
            "knownValues": [
              "auto",
              "light",
              "dark"
            ],
            "type": "string"
          },
          "collections": {
            "description": "Ordered list of the collections shown in the portfolio menu. Each entry references an app.zeens.collection record on the same repo. Array order is the menu order.",
            "items": {
              "ref": "#collectionRef",
              "type": "ref"
            },
            "maxLength": 50,
            "type": "array"
          },
          "columns": {
            "description": "Column count for grid/masonry layouts. Allowed 2-4. Default 3.",
            "maximum": 4,
            "minimum": 2,
            "type": "integer"
          },
          "createdAt": {
            "format": "datetime",
            "type": "string"
          },
          "enabled": {
            "description": "Whether the public site is live. When false, the subdomain 404s even if reserved.",
            "type": "boolean"
          },
          "featured": {
            "description": "Hand-picked photos under a heading the owner chooses, shown on the home page below the hero. Independent of any collection. Absent means no section.",
            "ref": "#featuredBlock",
            "type": "ref"
          },
          "gap": {
            "description": "Gap in pixels between photos. Allowed 0, 4, 8, 16, 24, 32. Default 8.",
            "maximum": 32,
            "minimum": 0,
            "type": "integer"
          },
          "hero": {
            "description": "The home page's hero — the owner's own photos across the top. ABSENT DOES NOT MEAN NO HERO: the render path falls back to the owner's 6 most-liked photos as a square grid, so a portfolio has a hero without its owner picking one. Present means they chose the photos themselves.",
            "ref": "#heroBlock",
            "type": "ref"
          },
          "home": {
            "description": "Optional plain-text heading + paragraph shown ABOVE the home page's existing content (the collection card grid, or homeCollection's photos when one is set). Absent means the home page renders exactly as it did before this field existed, so no portfolio changes without its owner acting.",
            "ref": "#homePage",
            "type": "ref"
          },
          "homeCollection": {
            "description": "DEPRECATED. AT-URI of a collection rendered inline as the portfolio's home page. Superseded by the portfolio's own home page (see `home`): the editor no longer offers this, and new records should not set it. Still honoured on read so existing sites don't change appearance, and the editor lets an owner who has it set turn it off.",
            "format": "at-uri",
            "type": "string"
          },
          "layout": {
            "description": "Photo layout applied to every collection shown in the portfolio. 'single' = one full-width column; 'grid' = uniform columns; 'masonry' = variable-height columns.",
            "knownValues": [
              "single",
              "grid",
              "masonry"
            ],
            "type": "string"
          },
          "menu": {
            "description": "How the navigation menu renders. Absent means the default: the home page shows the owner's collections.",
            "ref": "#menuSettings",
            "type": "ref"
          },
          "palette": {
            "description": "Id of the chosen color palette (each has a light+dark variant, defined app-side, e.g. 'mono'). Independent of typography. Unknown/absent falls back to the default at render time. (Legacy records may carry a bundled 'theme' field instead; read paths migrate it.)",
            "maxLength": 64,
            "type": "string"
          },
          "socials": {
            "description": "Which of the owner's profile social links the public site shows. ABSENT MEANS SHOW ALL — every portfolio created before this field keeps showing every link with no migration.",
            "ref": "#socialsVisibility",
            "type": "ref"
          },
          "subdomain": {
            "description": "The chosen subdomain slug (the 'alice' in alice.zeens.app). Lowercase DNS-safe label; uniqueness + reserved-word validation happen at write time and are mirrored into the PB portfolio_slugs table. Absent until the user picks one.",
            "maxLength": 63,
            "type": "string"
          },
          "tagline": {
            "description": "One-line tagline for the site. Mirrored onto site.standard.publication.description, since the portfolio IS the publication — this is the line other standard.site readers show under the name.",
            "maxGraphemes": 200,
            "maxLength": 2000,
            "type": "string"
          },
          "title": {
            "description": "Custom site title shown in the portfolio rail (free text, e.g. 'Ro', 'Alice Photography'). Falls back to the user's profile display name when absent.",
            "maxLength": 120,
            "type": "string"
          },
          "typography": {
            "description": "Id of the chosen typography pairing (font choice, defined app-side, e.g. 'space-grotesk'). Independent of palette. Unknown/absent falls back to the default at render time.",
            "maxLength": 64,
            "type": "string"
          }
        },
        "required": [
          "enabled",
          "createdAt"
        ],
        "type": "object"
      },
      "type": "record"
    },
    "menuSettings": {
      "description": "Navigation menu appearance. Its one property defaults to the behaviour that predates this def, so an absent `menu` never changes how a portfolio renders. Records written before the template took over navigation may still carry `icons` and `collections`; both axes were retired and readers ignore them.",
      "properties": {
        "homeCollections": {
          "description": "Whether the home page shows the owner's collections. ABSENT MEANS TRUE. When on, the home page shows the 3 most recently updated as a row linking through to the Galleries page that lists the rest. Lives here rather than on #homePage because it concerns the collections, not the home text, and an all-empty homePage is dropped from the record.",
          "type": "boolean"
        }
      },
      "type": "object"
    },
    "socialsVisibility": {
      "description": "Controls which profile social links the public portfolio shows. Filtering is applied server-side AFTER the Bluesky link is synthesised from the owner's handle, so hiding Bluesky works even though it is not stored on the profile record.",
      "properties": {
        "mode": {
          "description": "'all' (also the meaning of an absent socials field) shows every link; 'selected' shows only the platforms in `shown`; 'none' hides the block entirely, website included.",
          "knownValues": [
            "all",
            "selected",
            "none"
          ],
          "type": "string"
        },
        "shown": {
          "description": "Platform ids to show when mode is 'selected'. Ignored for other modes.",
          "items": {
            "maxLength": 32,
            "type": "string"
          },
          "maxLength": 32,
          "type": "array"
        },
        "website": {
          "description": "Whether the profile website link is shown. Absent means true.",
          "type": "boolean"
        }
      },
      "required": [
        "mode"
      ],
      "type": "object"
    }
  },
  "id": "app.zeens.portfolio",
  "lexicon": 1
}
```
