Version: 1.1 Status: Active Last Updated: 2026-01-26
- Vision & Principles
- Core Concepts
- Data Model
- View System Design
- Routing & URL Philosophy
- Sharing & Privacy Model
- Import & Enrichment Architecture
- Admin UX Philosophy
- Public UX Philosophy
- Security Invariants
- Extensibility Boundaries
- Known Tradeoffs
- Feature Gaps & Work
- Print & Export System
Facet is a self-hosted, privacy-respecting, single-owner personal profile platform. It is best understood as:
"A self-hosted, fully owned alternative to LinkedIn profiles — with views, versions, and sharing — built for individuals, not networks."
| Principle | Description |
|---|---|
| Single-owner by design | One person owns this instance. There are no users, no teams, no multi-tenancy. |
| Self-hosted | Runs on your infrastructure. One container, one port, one volume to backup. |
| Fully owned data | All data lives in SQLite. Export, backup, delete at will. |
| Quiet and intentional | No notifications, no feeds, no engagement metrics. |
| Powerful but not loud | Rich features that stay out of the way until needed. |
| Not social | No comments, likes, follows, or messaging. |
| Privacy first | Explicit sharing, no accidental disclosure, zero tracking. |
Think of Facet as:
- LinkedIn profile (the content part, not the network)
- Resume + portfolio + personal site combined
- With versions ("views") for different audiences:
- Recruiter view (focused on employment history)
- Conference view (speaking and projects)
- Consulting view (skills and case studies)
- Internal view (team-specific context)
- Personal view (friends and family)
- With strong privacy controls and explicit sharing
- With zero accidental exposure — unlisted and private content requires explicit access
This is something a thoughtful adult would host for themselves, not a SaaS product.
Facet will never include:
- Social features (feeds, likes, comments, follows)
- Messaging or notifications
- Multi-tenant SaaS features
- Analytics dashboards or engagement metrics
- Advertising or growth loops
- User tracking beyond basic access logging
The Profile is the singleton identity record. There is exactly one profile per Facet instance. It contains:
- Identity: Name, headline, location, avatar
- Narrative: Summary (Markdown)
- Contact: Email, links (GitHub, LinkedIn, etc.)
- Visibility: Public, unlisted, or private
The profile is the "global" identity that views (facets) inherit from and can override.
Content is organized into typed collections:
| Collection | Purpose | Key Fields |
|---|---|---|
| Experience | Work history | Company, title, dates, description, bullets |
| Projects | Portfolio items | Title, summary, tech stack, links, cover image |
| Education | Academic background | Institution, degree, field, dates |
| Certifications | Professional certs | Name, issuer, dates, credential ID |
| Awards | Recognition and achievements | Title, issuer, date, description |
| Skills | Categorized skills | Name, category, proficiency level |
| Posts | Blog/writing | Title, content (Markdown), published date |
| Talks | Speaking engagements | Title, event, date, slides/video URLs |
| Custom Content | User-defined sections | Title, content (Markdown), icon |
| Contact Methods | Protected contact info | Type, value, protection level |
| Testimonials | Social proof from others | Name, company, relationship, content |
Each content item has:
visibility: public / unlisted / privateis_draft: Draft items are never shown publiclysort_order: Manual ordering within collections
Views are curated versions of your profile for different audiences. A view defines:
- What to show: Which sections and items to include
- How to order: Custom ordering of sections and items
- What to override: Per-view headline, summary, CTA
- Who can access: Visibility level and authentication
Views are the core differentiator of Facet. They enable:
- Showing different content to recruiters vs. conference attendees
- Tailoring messaging for different contexts
- Sharing private content with specific people via tokens
Share tokens enable controlled access to unlisted views:
- Generated by admin, returned exactly once
- Stored as HMAC hash (raw token never stored)
- Optional expiration date and usage limits
- Revocable at any time
- Accessed via
/s/<token>URLs that redirect to clean canonical URLs
Sources represent external origins of content (currently GitHub repos):
- Track import origin for refresh capability
- Enable field-level locking (user customizations preserved)
- Generate Import Proposals for human review
AI Providers are user-supplied credentials for optional enrichment:
- Support for OpenAI, Anthropic, Ollama, custom endpoints
- API keys encrypted at rest (AES-256-GCM)
- Never auto-publish — all AI output goes through review
- Privacy modes: full README, summary only, none
The current implementation uses PocketBase collections with the following structure:
profile (singleton)
├── name, headline, location
├── summary (Markdown)
├── hero_image, avatar (files)
├── contact_email, contact_links (JSON)
└── visibility (public|unlisted|private)
experience
├── company, title, location
├── start_date, end_date
├── description (Markdown), bullets (JSON), skills (JSON)
├── media (files)
├── visibility, is_draft, sort_order
└── (password_hash - for item-level protection)
projects
├── title, slug (unique), summary
├── description (Markdown), tech_stack (JSON), links (JSON)
├── media, cover_image (files)
├── categories (JSON)
├── visibility, is_draft, is_featured, sort_order
├── source_id (FK→sources), field_locks (JSON), last_sync
└── (password_hash)
education
├── institution, degree, field
├── start_date, end_date, description
└── visibility, is_draft, sort_order
certifications
├── name, issuer, issue_date, expiry_date
├── credential_id, credential_url
└── visibility, is_draft, sort_order
skills
├── name, category, proficiency (expert|proficient|familiar)
└── visibility, sort_order
posts
├── title, slug (unique), excerpt
├── content (Markdown), cover_image
├── tags (JSON), published_at
└── visibility, is_draft
talks
├── title, event, event_url, date, location
├── description, slides_url, video_url
└── visibility, is_draft, sort_order
views
├── name, slug (unique, required)
├── description (internal note)
├── visibility (public|unlisted|password|private)
├── password_hash (for password visibility)
├── hero_headline, hero_summary (overrides)
├── cta_text, cta_url
├── sections (JSON: section configs with items)
├── is_active, is_default
└── (theme, custom CSS)
share_tokens
├── view_id (FK→views)
├── token_prefix (12 chars, indexed)
├── token_hash (HMAC-SHA256)
├── name (label)
├── expires_at, max_uses, use_count
├── is_active, last_used_at
sources
├── type (github)
├── identifier (owner/repo)
├── project_id (FK→projects, optional)
├── last_sync, sync_status, sync_log
ai_providers
├── name, type (openai|anthropic|ollama|custom)
├── api_key, api_key_encrypted
├── base_url, model
├── is_default, is_active
├── last_test, test_status
import_proposals
├── source_id (FK→sources)
├── project_id (FK→projects, optional for new)
├── proposed_data (JSON)
├── diff (JSON)
├── ai_enriched (boolean)
├── status (pending|applied|rejected)
└── applied_fields (JSON)
settings (key-value)
├── key (unique)
└── value (JSON)
Phase 1: Profile Completeness
Add to profile:
pronouns: Optional, displayed with nametagline: Short one-liner (distinct from headline)resume_pdf: Auto-generated or uploaded resume
Phase 2: Enhanced Experience/Projects
Add to experience:
company_logo: File upload for logosemployment_type: Full-time, part-time, contract, etc.
Add to projects:
status: Active, completed, archived, abandonedyear_started,year_ended: Simplified date displaycollaborators: JSON array of names
Phase 3: Awards & Publications
New collections:
awards: name, issuer, date, description, visibilitypublications: title, venue, date, url, type (paper|book|article), visibility
Phase 4: Custom Sections
New collection:
custom_sections: name, slug, content (Markdown), sort_order, visibility
This allows users to add arbitrary content sections without schema changes.
Views solve the audience problem: different people need to see different things.
A recruiter cares about employment history and skills. A conference organizer cares about talks and projects. A potential client cares about case studies and testimonials. Your family wants the personal touch.
Without views, you either:
- Show everything to everyone (privacy loss)
- Manually maintain multiple profiles (synchronization hell)
- Use visibility toggles (loses context, awkward)
Views provide curated lenses onto a single source of truth.
| Aspect | Default Behavior | View Override |
|---|---|---|
| Sections | All visible sections | Explicit include/exclude |
| Items | All visible items in section | Explicit item selection |
| Section Order | Default order | Custom per-view section ordering |
| Item Order | sort_order field | Custom per-view item ordering |
| Item Content | Source record values | Per-item field overrides |
| Headline | Profile headline | hero_headline override |
| Summary | Profile summary | hero_summary override |
| CTA | None | cta_text + cta_url |
| Theme | System default | (Future: per-view theme) |
Views store section configuration as JSON:
{
"sections": [
{
"section": "experience",
"enabled": true,
"items": ["id1", "id2", "id3"],
"order": 1,
"itemConfig": {
"id1": {
"order": 0,
"overrides": {
"title": "Senior UX Designer",
"description": "Led user research initiatives..."
}
},
"id2": { "order": 1 },
"id3": { "order": 2 }
}
},
{
"section": "projects",
"enabled": true,
"items": [],
"order": 2
},
{
"section": "education",
"enabled": false
}
]
}Design Decision: Empty items array means "include all visible items" (filtered by visibility and draft status). This avoids manual updates when adding new content.
Views can override specific fields on individual items without modifying the source record. This enables audience-specific framing of the same experience.
A product designer who straddles UX and Instructional Design can create two views from the same job history:
Source Record: "Product Designer @ Acme Corp"
├── title: "Product Designer"
├── description: "Designed products and training materials"
├── bullets: ["Led design system", "Created onboarding", "User research"]
View: "UX Designer"
└── Overrides:
├── title: "Senior UX Designer"
├── description: "Led user-centered design initiatives..."
└── bullets: ["Led design system", "Conducted 50+ user interviews", ...]
View: "Instructional Designer"
└── Overrides:
├── title: "Learning Experience Designer"
├── description: "Created scalable training programs..."
└── bullets: ["Developed onboarding curriculum", "Built LMS integrations", ...]
| Collection | Overridable Fields |
|---|---|
| Experience | title, description, bullets |
| Projects | title, summary, description |
| Education | degree, field, description |
| Skills | (none - include/exclude only) |
| Certifications | (none - include/exclude only) |
| Talks | title, description |
| Posts | (none - include/exclude only) |
Not Overridable: Company names, dates, institutions, issuers, URLs. These are factual and should remain consistent across views.
Render Priority (highest wins):
1. View itemConfig.overrides → If field has override, use it
2. Source record value → Otherwise, use original
The admin UI should clearly show:
- Which items have overrides (visual badge)
- Which specific fields are overridden (inline indicator)
- "Reset to original" action to clear overrides
- Side-by-side comparison of original vs. override
The homepage (/) renders the default view:
- Find view where
is_default=trueANDis_active=trueANDvisibility='public' - Fallback: First public active view by creation date
- Fallback: Legacy homepage (all public content aggregated)
Invariant: Only ONE view can have is_default=true. This is enforced by backend hooks that clear other defaults when setting a new one.
Views inherit from the profile and override selectively:
Profile (global identity)
├── name: "Jane Smith" ← Always used
├── headline: "Software Engineer" ← Can be overridden per view
├── summary: "I build things..." ← Can be overridden per view
└── contact_links: [...] ← Always used
View: "Recruiter"
├── hero_headline: "Senior Engineer | 10+ Years Experience" ← Override
├── hero_summary: "Looking for backend roles..." ← Override
└── cta_text: "Download Resume" → cta_url: "/resume.pdf" ← Addition
Not Overridable: Name, avatar, contact links. These are identity, not messaging.
Facet uses LinkedIn-style canonical URLs:
| Route | Purpose | Example |
|---|---|---|
/ |
Default public view | Homepage |
/<slug> |
Named view (canonical) | /recruiter, /conference |
/s/<token> |
Share link entry | Validates, sets cookie, redirects |
/v/<slug> |
Legacy route | 301 redirects to /<slug> |
Root-level slugs (/recruiter not /v/recruiter):
- Cleaner URLs for sharing
- More professional appearance
- LinkedIn uses this pattern (
linkedin.com/in/username)
Share link redirect (/s/<token> → /<slug>):
- Token never appears in final URL
- Token not leaked via browser history
- Token not leaked via Referer headers
- Cookie enables subsequent requests
These slugs cannot be used for views (enforced at frontend and backend):
admin, api, s, v, projects, posts, talks,
_app, _, assets, static,
favicon.ico, robots.txt, sitemap.xml,
health, healthz, ready, login, logout,
auth, oauth, callback, home, index, default, profile
Protection Layers:
- Frontend param matcher: Invalid slugs don't match
[slug=slug]route - Backend hook: Returns 400 when creating views with reserved slugs
A valid view slug must:
- Not be empty
- Not be in the reserved list (case-insensitive)
- Match pattern:
^[a-zA-Z0-9][a-zA-Z0-9_-]*$ - Not start with underscore or hyphen
- Be at most 100 characters
| Level | Access | HTTP Behavior | Use Case |
|---|---|---|---|
| public | Anyone | 200 OK | General audience |
| unlisted | Share token required | 200 with token, 404 without | Specific people |
| password | Password JWT required | Password prompt, then 200 | Protected content |
| private | Admin only | 404 (never 401/403) | Internal/draft |
Security Principle: Private and unlisted content returns 404, not 401/403, to prevent discovery of existence.
1. Admin generates token for unlisted view
POST /api/share/generate
→ Returns raw token ONCE (never stored)
2. Admin shares URL: https://example.com/s/<token>
3. User visits /s/<token>:
- Server validates token (HMAC verification)
- Sets httpOnly cookie (me_share_token)
- 302 redirect to /<slug>
- Token NOT in final URL
4. Subsequent requests to /<slug>:
- Token read from cookie
- Sent via X-Share-Token header
- View data returned with sections
| Property | Implementation |
|---|---|
| Storage | HMAC-SHA256 hash, not raw token |
| Lookup | Token prefix (12 chars) for O(1) indexed query |
| Comparison | Constant-time HMAC verification |
| View-bound | Each token tied to specific view_id |
| Expiration | Optional expires_at timestamp |
| Usage limits | Optional max_uses with use_count |
| Revocation | is_active flag for instant deactivation |
| Error responses | Uniform "invalid token" for all failure modes |
1. User visits /protected-view (no JWT cookie):
- Server returns { requiresPassword: true }
- Client shows password prompt
2. User submits password:
POST /api/password/check
- bcrypt comparison against password_hash
- Returns signed JWT (1-hour expiry)
3. Client stores JWT in httpOnly cookie
4. Subsequent requests include JWT:
- Authorization: Bearer <jwt>
- Server validates signature, expiry, view_id
- Returns view data
{
"vid": "<view-id>",
"iss": "facet",
"aud": "view-access",
"iat": 1234567890,
"exp": 1234571490,
"jti": "<random-id>"
}┌─────────────────────────────────────────────────────────┐
│ GitHub Import Flow │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. PREVIEW: Admin enters repo URL │
│ POST /api/github/preview │
│ → Fetch metadata, languages, topics, README │
│ → Display to user before import │
│ │
│ 2. IMPORT: Admin confirms import │
│ POST /api/github/import │
│ → Create Source record │
│ → Optionally invoke AI enrichment │
│ → Create ImportProposal (pending) │
│ │
│ 3. REVIEW: Admin reviews each field │
│ /admin/review/[id] │
│ → Apply / Ignore / Lock per field │
│ → Edit values before applying │
│ │
│ 4. APPLY: Create/update Project │
│ POST /api/proposals/{id}/apply │
│ → Apply selected fields │
│ → Update field_locks on project │
│ → Link source to project │
│ │
│ 5. REFRESH: Sync later │
│ POST /api/github/refresh/{sourceId} │
│ → Fetch fresh data │
│ → Respect field_locks │
│ → Create new proposal for review │
│ │
└─────────────────────────────────────────────────────────┘
When a user customizes a field (edits it beyond the imported value), they can "lock" it:
{
"field_locks": {
"title": false, // Can be updated on refresh
"summary": true, // Locked - preserved on refresh
"description": true // Locked
}
}Behavior: Locked fields are skipped during refresh import. Unlocked fields appear in the new proposal for review.
AI enrichment is optional and user-controlled:
- User provides their own API keys (encrypted at rest)
- User chooses privacy mode:
- Full: Send full README to AI
- Summary: Send first 500 chars only
- None: No AI, use raw GitHub data
- AI output goes to proposal, not directly to project
- User reviews and can edit every AI-generated field
AI Guardrails (in prompt):
- "Do not invent metrics or statistics"
- "Stay factual, avoid marketing language"
- "Use neutral, professional tone"
| Principle | Implementation |
|---|---|
| Calm, not CMS | No dashboard for its own sake. Information density is earned. |
| Empty ≠ broken | First-run shows welcoming copy, not error states or zeros. |
| No analytics theater | No graphs showing "0 views this week". |
| Tending, not managing | Editing should feel like gardening your profile, not filling forms. |
When content is missing, show helpful guidance:
- Dashboard empty: "This is your space. You might start by adding your profile..."
- No views: "Views let you show different versions of your profile to different audiences."
- No activity: "Nothing here yet — and that's okay."
- No AI providers: "AI enrichment is optional. Add your own API key if you'd like AI-assisted summaries."
/admin Dashboard (overview)
/admin/homepage Homepage configuration
-- Your Information --
/admin/contacts Contact methods (with protection levels)
/admin/experience Manage work history
/admin/projects Manage portfolio
/admin/education Manage education
/admin/certifications Manage certifications
/admin/awards Manage awards
/admin/skills Manage skills
/admin/custom Custom content sections
/admin/import GitHub import & Resume AI parsing
-- Your Voice --
/admin/posts Blog posts
/admin/talks Speaking engagements
-- Testimonials --
/admin/testimonials Manage testimonials
/admin/testimonials/requests Request links
-- Views & Sharing --
/admin/views Manage curated views/facets
/admin/views/new Create new view
/admin/views/[id] Edit specific view
/admin/tokens Manage share tokens
/admin/media Media library
-- Settings --
/admin/settings Main settings
/admin/settings/account Account & security
/admin/settings/appearance Appearance settings
/admin/settings/general General settings
/admin/settings/analytics Analytics (Google Analytics)
/admin/settings/integrations AI providers & integrations
/admin/settings/tags Admin tags
/admin/settings/about About Facet (changelog, version)
- Consistent icons: SVG icon set in
$lib/icons.ts, no emojis - Destructive actions: Red styling, confirmation dialogs
- Loading states: Skeleton loaders, not spinners everywhere
- Dark mode: Full support via Tailwind dark classes
| Principle | Implementation |
|---|---|
| Readability first | Clean typography, generous whitespace |
| Fast | SSR, minimal JS, optimized images |
| Accessible | Semantic HTML, ARIA labels, keyboard nav |
| Printable | Print-friendly CSS for resume printing |
| SEO basics | Meta tags, Open Graph, canonical URLs |
┌────────────────────────────────────────┐
│ Profile Hero │
│ Avatar Name Headline Location │
│ Summary (Markdown) │
│ CTA Button (if view has one) │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ Experience Section │
│ [Cards with company, title, dates] │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ Projects Section │
│ [Grid of project cards with covers] │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ Education Section │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ Skills Section │
│ [Grouped by category] │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ Footer │
│ Contact links • Facet │
└────────────────────────────────────────┘
For password-protected views:
- Minimal, clean prompt
- No indication of what content is behind it
- Single password field + submit
- Error: "Incorrect password" (not "view not found")
| Invariant | Enforcement |
|---|---|
| Private content returns 404 | Never 401/403 for private/unlisted without token |
| Tokens never stored raw | HMAC hash only in database |
| API keys encrypted at rest | AES-256-GCM with derived key |
| No public collection access | All collections require auth for direct API |
| Rate limiting on auth endpoints | Strict tier (5/min) on password check |
Deny-by-default: All PocketBase collections require authentication for direct HTTP access.
Public data flows through custom endpoints:
/api/view/{slug}/access → View metadata
/api/view/{slug}/data → View content (with visibility checks)
/api/homepage → Legacy aggregated public content
These endpoints use server-side queries that bypass collection rules,
allowing them to serve public content while maintaining access control.
From master ENCRYPTION_KEY:
- Encryption key:
SHA256(master + ":encryption") - HMAC key:
SHA256(master + ":hmac") - JWT signing key:
SHA256(master + ":jwt")
| Tier | Rate | Burst | Endpoints |
|---|---|---|---|
| Strict | 5/min | 3 | /api/password/check |
| Moderate | 10/min | 5 | /api/share/validate |
| Normal | 60/min | 10 | /api/view/{slug}/*, /api/homepage |
| Extension Point | Mechanism |
|---|---|
| New import sources | Add to sources.type enum, create service |
| New AI providers | Add to ai_providers.type enum, extend AIService |
| Custom sections | Future: custom_sections collection |
| Themes | Future: Per-view CSS/theme selection |
| Boundary | Reason |
|---|---|
| Multi-user | Fundamental single-owner assumption |
| Social features | Violates core philosophy |
| Tracking/analytics | Privacy-first commitment |
| SaaS features | Self-hosted by design |
Facet does not currently support plugins. Future extensibility may include:
- Custom Go hooks in a designated directory
- Theme packages (CSS + layout overrides)
- Import adapters for new sources
| Tradeoff | Reason |
|---|---|
| SQLite only | Perfect for single-user, no need for horizontal scaling |
| Single container | Simplest deployment, one port to expose |
| No CDN for files | Local file storage keeps it simple; S3 optional |
| No JWT revocation | Stateless tokens; 1-hour expiry is acceptable |
| PocketBase pre-v1.0 | Widely used, pinned version, documented upgrade path |
| Constraint | Implication |
|---|---|
| One profile per instance | Family members need separate instances |
| No scheduled sync | GitHub refresh is manual (could add cron job) |
| No granular permissions | Admin is all-or-nothing |
| No version history | Changes overwrite; backup externally |
Based on codebase analysis, these features are incomplete or missing:
| Gap | Current State | Impact |
|---|---|---|
✅ Complete: /projects/{slug} |
||
✅ Complete: /posts/{slug} |
||
| ✅ Complete: Public display with video embeds | ||
| ✅ Complete: Public display with issuer grouping | ||
| ✅ Complete: Full editor with section/item selection | ||
✅ Complete: /admin/media with listing, filters, delete |
||
✅ Complete: /admin/tokens with full CRUD |
||
| Scheduled GitHub sync | Not implemented | Manual refresh only |
| ✅ Complete: Print stylesheet + AI resume generation | ||
| ✅ Partial: Accent colors, custom CSS, dark mode | ||
| ✅ Complete: "The Doctor" profile with one-click toggle | ||
| Audit logging | Minimal | Cannot review full access history |
| ✅ Complete: Full system with request links, verification | ||
| ✅ Complete: User-defined content sections | ||
| ✅ Complete: 4-tier protection, per-view visibility | ||
| ✅ Complete: Check GitHub for updates |
Core content pages: Project details, posts, talks, certifications✅ CompleteView editor core: Section toggles, item selection, hero overrides✅ CompleteView editor improvements: Drag-drop ordering, preview pane✅ CompleteShare token management: Full CRUD UI with usage stats✅ CompleteResume export: Generate PDF from profile/view✅ Complete (AI generation + print)Theme system: Light/dark modes, color customization✅ Complete (accent colors, custom CSS)- Scheduled sync: Cron-based GitHub refresh
Media library: Image optimization (thumb/WebP/srcset), storage insights✅ CompleteDemo mode: Production-safe showcase persona with multiple views✅ Complete- Audit log: Access history for share tokens (minimal currently)
Testimonials: Social proof collection✅ CompleteContact protection: Per-view contact visibility✅ Complete- 🔜 View Analytics Dashboard: Surface existing usage data
- 🔜 QR Codes: Generate for views/share links
- 🔜 Webhooks: Notify external services on events
Facet includes a comprehensive print stylesheet that optimizes public views for printing and PDF generation via the browser's native Print function (Ctrl+P / Cmd+P).
| Goal | Implementation |
|---|---|
| ATS-friendly | Clean text, minimal formatting, semantic structure |
| One-click print | Print button visible on public views |
| Readable | Optimized typography, appropriate spacing |
| Complete | All visible content printed, nothing hidden |
| No dark mode | Forces light colors for printing |
-
Hidden Elements
- Theme toggle button
- Print button itself
- Interactive elements (hover states)
- Background gradients and decorative images
-
Typography Adjustments
- Serif font for body text (better for printing)
- Slightly smaller base font size
- Increased line height for readability
- Black text on white background
-
Layout Changes
- Full-width content (no max-width constraints)
- Reduced padding and margins
- Cards rendered without shadows/borders
- Page breaks avoided inside sections
-
Link Handling
- URLs displayed after link text
- Contact links shown with full URLs
- External links marked appropriately
@media print {
/* Force light mode colors */
* { color-adjust: exact; -webkit-print-color-adjust: exact; }
/* Hide UI elements */
.print\\:hidden { display: none !important; }
/* Typography */
body { font-family: Georgia, serif; font-size: 11pt; }
/* Layout */
main { max-width: 100%; padding: 0; }
.card { box-shadow: none; border: 1px solid #e5e7eb; }
/* Page breaks */
section { page-break-inside: avoid; }
article { page-break-inside: avoid; }
}A print button is displayed on public view pages (not admin). The button:
- Appears in a non-intrusive location (top-right, near theme toggle)
- Has a printer icon for clarity
- Triggers
window.print()on click - Is hidden when printing (class
print:hidden)
These export features are planned for later phases:
| Feature | Status | Notes |
|---|---|---|
| Server-side PDF | Deferred | Would require headless browser or Go PDF library |
| Data export (JSON) | Deferred | Export all profile data for backup |
| Data export (YAML) | Deferred | Export in Facet format |
| Static HTML snapshot | Deferred | Self-contained offline version |
| Method | Path | Rate Limit | Description |
|---|---|---|---|
| GET | /api/default-view |
Normal | Get default view slug |
| GET | /api/view/{slug}/access |
Normal | Get view access requirements |
| GET | /api/view/{slug}/data |
Normal | Get view content |
| GET | /api/project/{slug} |
Normal | Get public project details |
| GET | /api/homepage |
Normal | Legacy aggregated content |
| POST | /api/share/validate |
Moderate | Validate share token |
| POST | /api/password/check |
Strict | Validate view password |
| Method | Path | Description |
|---|---|---|
| POST | /api/share/generate |
Generate new share token |
| POST | /api/share/revoke/{id} |
Revoke share token |
| POST | /api/github/preview |
Preview GitHub repo |
| POST | /api/github/import |
Import GitHub repo |
| POST | /api/github/refresh/{id} |
Refresh source |
| POST | /api/ai/test/{id} |
Test AI provider |
| POST | /api/ai/enrich |
Enrich content with AI |
| POST | /api/proposals/{id}/apply |
Apply import proposal |
| POST | /api/proposals/{id}/reject |
Reject import proposal |
| POST | /api/password/set |
Set view password |
| Variable | Required | Default | Description |
|---|---|---|---|
ENCRYPTION_KEY |
Yes | — | 32-byte hex key for encryption |
PORT |
No | 8080 |
Public port |
APP_URL |
No | http://localhost:8080 |
Public URL |
TRUST_PROXY |
No | false |
Trust proxy headers for IP |
ADMIN_EMAILS |
No | — | Comma-separated admin allowlist |
ADMIN_ENABLED |
No | false |
Enable PocketBase admin UI |
DATA_PATH |
No | ./data |
Database and uploads path |
SEED_DATA |
No | — | Seed mode: dev for dev profile, unset for none |
LOG_LEVEL |
No | info |
Logging verbosity |
This document is the authoritative design reference for Facet. Update it as the product evolves.