Compare commits

..

10 commits
dev ... main

Author SHA1 Message Date
Josh
07a89195d0 fix(owner-tools): fix UnoCSS prebuild script
Some checks are pending
ci/woodpecker/push/build Pipeline is running
- Use npx to run unocss CLI
- Regenerate uno-generated.css with 88 utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:00:36 +02:00
Josh
5c34bef790 fix(owner-tools): define process.env.NODE_ENV for browser bundle
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Fixes ReferenceError: process is not defined in production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:11:25 +02:00
Josh
6ba25d0113 fix(owner-tools): resolve merge conflict and fix UnoCSS build
All checks were successful
ci/woodpecker/push/build Pipeline was successful
- Use UnoCSS CLI to pre-generate CSS for production build
- Add prebuild script to generate uno-generated.css
- Alias to virtual:uno.css in dev mode for HMR
- Remove UnoCSS vite plugin from build (only use in dev)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:51:07 +02:00
Josh
58fd9c0a55 chore: cleanup unused files and update gitignore
- Remove MONETIZATION.md and README.md
- Add dist/ to gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:49:44 +02:00
Josh
a045da82b7 chore: cleanup unused files and update gitignore
Some checks failed
ci/woodpecker/push/build Pipeline failed
- Remove MONETIZATION.md and README.md
- Add dist/ to gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:34:27 +02:00
Josh
ac04d7f346 chore(deps): update auth middleware and dependencies
- Add OptionalSessionMiddleware for non-required auth checks
- Add GetUserID helper function
- Update import paths in auth and main
- Update docker-compose with frontend build configuration
- Clean up go.mod and go.sum

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:06:20 +02:00
Josh
119e3b7a6d feat(server): add owner-tools injection and inline code theme CSS
- Add owner-tools serving and injection for blog owners
- Inline code theme CSS in templates for soft reload support
- Update import paths from github.com/writekitapp to writekit
- Add optional session middleware for owner detection
- Update platform index with improved UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:06:20 +02:00
Josh
771ff7615a feat(markdown): add enhanced code blocks with syntax highlighting
- Add codeblock.go for custom Goldmark renderer
- Add code block header with language icon, filename, copy button
- Use Chroma for syntax highlighting with class-based output
- Add GenerateChromaCSS for theme CSS generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:06:20 +02:00
Josh
6e2959f619 refactor: move tenant templates to internal/tenant
- Move internal/build/ to internal/tenant/
- Rename assets for clarity
- Add tenant-blog.js for shared blog functionality
- Update style.css with improved code block styling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:06:19 +02:00
Josh
bef5dd4437 refactor: move studio to frontends workspace
- Move studio from root to frontends/studio/
- Add owner-tools frontend for live blog admin UI
- Add shared ui component library
- Set up npm workspaces for frontends
- Add enhanced code block extension for editor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:06:19 +02:00
145 changed files with 10625 additions and 1494 deletions

3
.gitignore vendored
View file

@ -10,4 +10,7 @@ node_modules/
.vscode/ .vscode/
*.swp *.swp
dist/
tmp tmp
.vite/

View file

@ -1,16 +1,20 @@
FROM node:22-alpine AS studio FROM node:22-alpine AS frontends
WORKDIR /app/studio WORKDIR /app
COPY studio/package*.json ./ COPY frontends/package*.json ./
COPY frontends/studio/package*.json ./studio/
COPY frontends/owner-tools/package*.json ./owner-tools/
COPY frontends/ui/package.json ./ui/
RUN npm ci RUN npm ci
COPY studio/ ./ COPY frontends/ ./
RUN npm run build RUN npm run build --workspace=studio --workspace=owner-tools
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=studio /app/studio/dist ./studio/dist COPY --from=frontends /app/studio/dist ./frontends/studio/dist
COPY --from=frontends /internal/tenant/assets/js/owner-tools.js ./internal/tenant/assets/js/owner-tools.js
RUN CGO_ENABLED=0 go build -o writekit . RUN CGO_ENABLED=0 go build -o writekit .
FROM alpine:3.21 FROM alpine:3.21

View file

@ -1,121 +0,0 @@
# WriteKit Monetization
## Tiers
| Tier | Price | Billing |
|------|-------|---------|
| Free | $0 | - |
| Pro | $5/mo or $49/year | Lemon Squeezy |
## Feature Matrix
| Feature | Free | Pro |
|---------|------|-----|
| Subdomain (you.writekit.dev) | Yes | Yes |
| Custom domain | No | Yes |
| "Powered by WriteKit" badge | Required | Hidden |
| Analytics retention | 7 days | 90 days |
| API requests | 100/hour | 1000/hour |
| Webhooks | 3 endpoints | 10 endpoints |
| Webhook deliveries | 100/day | 1000/day |
| Plugins | 3 max | 10 max |
| Plugin executions | 1000/day | 10000/day |
| Posts | Unlimited | Unlimited |
| Assets | Unlimited | Unlimited |
| Comments/Reactions | Unlimited | Unlimited |
## Upgrade Triggers
1. **Custom domain** - Primary trigger, most valuable to users
2. **Remove badge** - Secondary, vanity upgrade
3. **Analytics depth** - 7 days feels limiting for serious bloggers
4. **Rate limits** - For power users/headless CMS use case
## Positioning
**Tagline:** "Your blog, your domain, your data."
**Key messages:**
- No paywalls, no algorithms, no surprises
- 70% cheaper than Ghost ($5 vs $18/mo)
- Own your content, export anytime
- API-first, developer-friendly
## Implementation
### Backend Config
```go
// internal/config/tiers.go
type Tier string
const (
TierFree Tier = "free"
TierPro Tier = "pro"
)
type TierConfig struct {
Price int // cents/month
AnnualPrice int // cents/year
CustomDomain bool
BadgeRequired bool
AnalyticsRetention int // days
APIRateLimit int // requests/hour
MaxWebhooks int
WebhookDeliveries int // per day
MaxPlugins int
PluginExecutions int // per day
}
var Tiers = map[Tier]TierConfig{
TierFree: {
Price: 0,
AnnualPrice: 0,
CustomDomain: false,
BadgeRequired: true,
AnalyticsRetention: 7,
APIRateLimit: 100,
MaxWebhooks: 3,
WebhookDeliveries: 100,
MaxPlugins: 3,
PluginExecutions: 1000,
},
TierPro: {
Price: 500, // $5
AnnualPrice: 4900, // $49
CustomDomain: true,
BadgeRequired: false,
AnalyticsRetention: 90,
APIRateLimit: 1000,
MaxWebhooks: 10,
WebhookDeliveries: 1000,
MaxPlugins: 10,
PluginExecutions: 10000,
},
}
```
### Database
Add to `tenants` table:
```sql
tier TEXT NOT NULL DEFAULT 'free'
```
### Frontend
Update `BillingPage.tsx` with:
- Current plan display
- Feature comparison
- Upgrade button → Lemon Squeezy checkout
### Enforcement Points
| Feature | File | Check |
|---------|------|-------|
| Custom domain | `server/domain.go` | Block if tier != pro |
| Badge | Blog templates | Show if tier == free |
| Analytics | `tenant/analytics.go` | Filter by retention days |
| API rate limit | `server/middleware.go` | Rate limiter per tier |
| Webhooks count | `tenant/webhooks.go` | Check on create |
| Plugins count | `tenant/plugins.go` | Check on create |

View file

@ -1,61 +0,0 @@
# WriteKit - Local Development
## Prerequisites
- Docker & Docker Compose
- GitHub OAuth App (for login)
## Setup GitHub OAuth
1. Go to https://github.com/settings/developers
2. New OAuth App:
- Name: `WriteKit Local`
- Homepage: `http://writekit.lvh.me`
- Callback: `http://writekit.lvh.me/auth/github/callback`
3. Copy Client ID and Secret
## Run
```bash
# Set OAuth credentials
export GITHUB_CLIENT_ID=your_client_id
export GITHUB_CLIENT_SECRET=your_client_secret
# Start
docker compose up --build
```
Or create `.env` file:
```
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
```
## Access
- **Platform**: http://writekit.lvh.me
- **Traefik dashboard**: http://localhost:8080
- **MinIO console**: http://localhost:9001 (minioadmin/minioadmin)
## Create a demo
```bash
curl -X POST http://writekit.lvh.me/api/demo
```
Returns subdomain like `demo-abc123.writekit.lvh.me` - works automatically, no hosts file needed.
## Environment Variables
### Required
| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `DOMAIN` | Base domain |
| `BASE_URL` | Full URL for OAuth callbacks |
| `SESSION_SECRET` | Cookie encryption (32+ chars) |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth secret |
### Optional
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID/SECRET` | Google OAuth |
| `DISCORD_CLIENT_ID/SECRET` | Discord OAuth |
| `R2_*` | Cloudflare R2 storage |
| `IMAGINARY_URL` | Image processing service |
| `CLOUDFLARE_API_TOKEN/ZONE_ID` | Analytics |

View file

@ -57,14 +57,25 @@ services:
vite: vite:
image: node:20-alpine image: node:20-alpine
working_dir: /app/studio working_dir: /app/frontends
environment: environment:
- CHOKIDAR_USEPOLLING=true - CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=100 - CHOKIDAR_INTERVAL=100
command: sh -c "npm install && npm run dev -- --host" command: sh -c "npm install && npm run dev:studio -- --host"
volumes: volumes:
- ./studio:/app/studio - ./frontends:/app/frontends
- vite_node_modules:/app/studio/node_modules - frontends_node_modules:/app/frontends/node_modules
owner-tools:
image: node:20-alpine
working_dir: /app/frontends
environment:
- CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=100
command: sh -c "while [ ! -f /app/frontends/node_modules/.bin/vite ]; do sleep 5; done && npm run dev:owner-tools -- --host"
volumes:
- ./frontends:/app/frontends
- frontends_node_modules:/app/frontends/node_modules
app: app:
image: cosmtrek/air image: cosmtrek/air
@ -76,6 +87,7 @@ services:
air_wd: /app air_wd: /app
ENV: local ENV: local
VITE_URL: http://vite:5173 VITE_URL: http://vite:5173
OWNER_TOOLS_URL: http://owner-tools:5174
DATABASE_URL: postgres://writekit:writekit@postgres:5432/writekit?sslmode=disable DATABASE_URL: postgres://writekit:writekit@postgres:5432/writekit?sslmode=disable
DOMAIN: writekit.lvh.me DOMAIN: writekit.lvh.me
BASE_URL: http://writekit.lvh.me BASE_URL: http://writekit.lvh.me
@ -116,4 +128,4 @@ volumes:
minio_data: minio_data:
app_data: app_data:
go_mod: go_mod:
vite_node_modules: frontends_node_modules:

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Owner Tools Dev</title>
<style>
:root {
--accent: #10b981;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #fafafa;
min-height: 100vh;
}
.mock-blog {
max-width: 680px;
margin: 0 auto;
padding: 60px 20px;
}
.mock-blog h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #18181b;
}
.mock-blog p {
font-size: 1.125rem;
line-height: 1.75;
color: #3f3f46;
margin-bottom: 1.5rem;
}
.mock-blog pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="mock-blog">
<h1>Sample Blog Post</h1>
<p>
This is a mock blog page for developing the owner tools panel.
The settings panel should appear when you click the cog icon in the bottom right.
</p>
<p>
Changes to accent color should update the <code>--accent</code> CSS variable
and you should see it reflected immediately.
</p>
<pre><code>function hello() {
console.log("Hello, world!");
}</code></pre>
<p style="color: var(--accent); font-weight: 600;">
This text uses the accent color.
</p>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2553
frontends/owner-tools/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "owner-tools",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "npx unocss \"src/**/*.tsx\" -o src/uno-generated.css",
"build": "vite build",
"watch": "vite build --watch"
},
"dependencies": {
"@writekit/ui": "*",
"@nanostores/react": "^1.0.0",
"@unocss/reset": "^66.5.12",
"nanostores": "^1.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"unocss": "^66.5.12"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.82",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@unocss/cli": "^66.5.12",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,69 @@
import { useStore } from '@nanostores/react'
import { Icons } from '@writekit/ui'
import { SettingsPanel } from './SettingsPanel'
import { RegeneratingOverlay } from './RegeneratingOverlay'
import {
$panelOpen,
$regenerating,
$error,
$settings,
openPanel,
closePanel,
} from './stores/app'
export function App() {
const open = useStore($panelOpen)
const regenerating = useStore($regenerating)
const error = useStore($error)
const settings = useStore($settings)
const hasSettings = Object.keys(settings).length > 0
return (
<>
<RegeneratingOverlay visible={regenerating} />
<button
className="pointer-events-auto fixed bottom-5 left-5 w-12 h-12 rounded-full bg-text text-white border-none cursor-pointer flex items-center justify-center shadow-lg hover:scale-105 hover:bg-zinc-700 transition-all z-99999"
onClick={openPanel}
aria-label="Site settings"
>
<Icons.Settings className="w-5 h-5" />
</button>
{open && (
<div
className="pointer-events-auto fixed inset-0 bg-black/40 z-100000 flex justify-start"
onClick={closePanel}
>
<div
className="w-full max-w-100 h-full bg-white shadow-xl overflow-y-auto animate-[wk-slide-in_0.2s_ease-out]"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border sticky top-0 bg-white">
<h2 className="m-0 text-lg font-semibold text-text">Site Settings</h2>
<button
className="bg-transparent border-none cursor-pointer p-1 text-muted hover:text-text"
onClick={closePanel}
aria-label="Close"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
{error && (
<div className="mx-5 mt-4 p-3 bg-red-50 text-danger rounded-lg text-sm">
{error}
</div>
)}
{hasSettings ? (
<SettingsPanel />
) : (
<div className="py-10 px-5 text-center text-muted">Loading...</div>
)}
</div>
</div>
)}
</>
)
}

View file

@ -0,0 +1,18 @@
import { Icons } from '@writekit/ui'
interface Props {
visible: boolean
}
export function RegeneratingOverlay({ visible }: Props) {
if (!visible) return null
return (
<div className="pointer-events-auto fixed inset-0 z-[100001] bg-black/60 flex items-center justify-center">
<div className="bg-white px-6 py-4 rounded-lg shadow-xl flex items-center gap-3">
<Icons.Loader className="w-5 h-5 animate-spin text-accent" />
<span className="text-sm font-medium text-text">Regenerating site...</span>
</div>
</div>
)
}

View file

@ -0,0 +1,97 @@
import { useStore } from '@nanostores/react'
import { Button, Input, Textarea, Select } from '@writekit/ui'
import { $settings, $schema, $dirty, updateSetting, saveSettings } from './stores/app'
import type { SettingDefinition } from './api'
export function SettingsPanel() {
const settings = useStore($settings)
const schema = useStore($schema)
const dirty = useStore($dirty)
function renderField(def: SettingDefinition) {
const value = settings[def.key] ?? def.default ?? ''
if (def.type === 'color') {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<div className="flex gap-2">
<input
type="color"
value={value}
onChange={e => updateSetting(def.key, e.target.value)}
className="w-11 h-11 p-0.5 border border-border rounded-lg cursor-pointer"
/>
<Input
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
className="flex-1"
/>
</div>
</label>
)
}
if (def.type === 'select' && def.options) {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Select
value={value}
onChange={v => updateSetting(def.key, v)}
options={def.options}
/>
</label>
)
}
if (def.type === 'textarea') {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Textarea
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
rows={4}
className="resize-y min-h-20"
/>
</label>
)
}
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Input
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
/>
</label>
)
}
return (
<div className="p-5">
<section className="mb-6">
<h3 className="text-xs font-semibold text-muted uppercase tracking-wide mb-4">
Appearance
</h3>
{schema.map(renderField)}
</section>
<div className="sticky bottom-0 py-4 bg-white border-t border-border -mx-5 px-5">
<Button
variant="accent"
onClick={saveSettings}
disabled={!dirty}
className="w-full"
>
Save Changes
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,39 @@
const BASE = '/api/studio'
export interface Settings {
[key: string]: string
}
export interface SettingOption {
value: string
label: string
}
export interface SettingDefinition {
key: string
type: 'color' | 'select' | 'text' | 'textarea'
label: string
options?: SettingOption[]
default?: string
}
export async function getSettingsSchema(): Promise<SettingDefinition[]> {
const res = await fetch(`${BASE}/settings/schema`)
if (!res.ok) throw new Error('Failed to fetch settings schema')
return res.json()
}
export async function getSettings(): Promise<Settings> {
const res = await fetch(`${BASE}/settings`)
if (!res.ok) throw new Error('Failed to fetch settings')
return res.json()
}
export async function saveSettings(settings: Partial<Settings>): Promise<void> {
const res = await fetch(`${BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
})
if (!res.ok) throw new Error('Failed to save settings')
}

View file

@ -0,0 +1,39 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'
import reset from '@unocss/reset/tailwind.css?inline'
import css from './uno-generated.css?inline'
function mount() {
const host = document.createElement('div')
host.id = 'writekit-owner-tools'
document.body.appendChild(host)
const shadow = host.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = reset + css + `
:host {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`
shadow.appendChild(style)
const container = document.createElement('div')
container.style.cssText = `
position:fixed;inset:0;z-index:99999;pointer-events:none;
--un-bg-opacity:100%;--un-text-opacity:100%;--un-border-opacity:100%;
--un-ring-opacity:100%;--un-shadow-opacity:100%;
`.replace(/\s+/g, '')
shadow.appendChild(container)
createRoot(container).render(<App />)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount)
} else {
mount()
}

View file

@ -0,0 +1,64 @@
import { atom, map, onMount } from 'nanostores'
import { getSettings, getSettingsSchema, saveSettings as apiSaveSettings } from '../api'
import type { Settings, SettingDefinition } from '../api'
export const $panelOpen = atom(false)
export const $regenerating = atom(false)
export const $error = atom<string | null>(null)
export const $settings = map<Settings>({})
export const $schema = atom<SettingDefinition[]>([])
export const $dirty = atom(false)
export function openPanel() {
$panelOpen.set(true)
}
export function closePanel() {
$panelOpen.set(false)
}
export function updateSetting(key: string, value: string) {
$settings.setKey(key, value)
$dirty.set(true)
}
async function softReload() {
const res = await fetch(window.location.href)
if (!res.ok) throw new Error('Failed to fetch page')
const html = await res.text()
const parser = new DOMParser()
const newDoc = parser.parseFromString(html, 'text/html')
const currentPage = document.getElementById('page')
const newPage = newDoc.getElementById('page')
if (currentPage && newPage) {
currentPage.outerHTML = newPage.outerHTML
}
}
export async function saveSettings() {
$regenerating.set(true)
$error.set(null)
try {
await apiSaveSettings($settings.get())
await softReload()
$regenerating.set(false)
$dirty.set(false)
} catch {
$regenerating.set(false)
$error.set('Failed to save')
}
}
onMount($settings, () => {
getSettings()
.then(data => $settings.set(data))
.catch(() => $error.set('Failed to load settings'))
getSettingsSchema()
.then(data => $schema.set(data))
.catch(() => {})
})

View file

@ -0,0 +1,589 @@
/* layer: properties */
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))){*, ::before, ::after, ::backdrop{--un-bg-opacity:100%;--un-text-opacity:100%;--un-border-opacity:100%;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;}}
@property --un-text-opacity{syntax:"<percentage>";inherits:false;initial-value:100%;}
@property --un-border-opacity{syntax:"<percentage>";inherits:false;initial-value:100%;}
@property --un-bg-opacity{syntax:"<percentage>";inherits:false;initial-value:100%;}
@property --un-inset-ring-color{syntax:"*";inherits:false;}
@property --un-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000;}
@property --un-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000;}
@property --un-inset-shadow-color{syntax:"*";inherits:false;}
@property --un-ring-color{syntax:"*";inherits:false;}
@property --un-ring-inset{syntax:"*";inherits:false;}
@property --un-ring-offset-color{syntax:"*";inherits:false;}
@property --un-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000;}
@property --un-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0px;}
@property --un-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000;}
@property --un-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000;}
@property --un-shadow-color{syntax:"*";inherits:false;}
@property --un-scale-x{syntax:"*";inherits:false;initial-value:1;}
@property --un-scale-y{syntax:"*";inherits:false;initial-value:1;}
@property --un-scale-z{syntax:"*";inherits:false;initial-value:1;}
@property --un-blur{syntax:"*";inherits:false;}
@property --un-brightness{syntax:"*";inherits:false;}
@property --un-contrast{syntax:"*";inherits:false;}
@property --un-drop-shadow{syntax:"*";inherits:false;}
@property --un-grayscale{syntax:"*";inherits:false;}
@property --un-hue-rotate{syntax:"*";inherits:false;}
@property --un-invert{syntax:"*";inherits:false;}
@property --un-saturate{syntax:"*";inherits:false;}
@property --un-sepia{syntax:"*";inherits:false;}
/* layer: theme */
:root, :host {
--spacing: 0.25rem;
--default-transition-timingFunction: cubic-bezier(0.4, 0, 0.2, 1);
--default-transition-duration: 150ms;
--colors-black: #000;
--fontWeight-semibold: 600;
--radius-lg: 0.5rem;
--colors-text: #18181b;
--colors-white: #fff;
--colors-border: #e4e4e7;
--colors-muted: #71717a;
--colors-red-50: oklch(97.1% 0.013 17.38);
--colors-danger: #ef4444;
--text-lg-fontSize: 1.125rem;
--text-lg-lineHeight: 1.75rem;
--text-sm-fontSize: 0.875rem;
--text-sm-lineHeight: 1.25rem;
--colors-zinc-700: oklch(37% 0.013 285.805);
--tracking-wide: 0.025em;
--text-xs-fontSize: 0.75rem;
--text-xs-lineHeight: 1rem;
--fontWeight-medium: 500;
--colors-bg: #fafafa;
--font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
--colors-accent: #10b981;
--font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
--default-font-family: var(--font-sans);
--default-monoFont-family: var(--font-mono);
}
/* layer: base */
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Remove default margins and padding
3. Reset all borders.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
box-sizing: border-box; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 2 */
border: 0 solid; /* 3 */
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS.
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
tab-size: 4; /* 3 */
font-family: var(
--default-font-family,
ui-sans-serif,
system-ui,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
); /* 4 */
font-feature-settings: var(--default-font-featureSettings, normal); /* 5 */
font-variation-settings: var(--default-font-variationSettings, normal); /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Reset the default border style to a 1px solid border.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: var(
--default-monoFont-family,
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
'Liberation Mono',
'Courier New',
monospace
); /* 1 */
font-feature-settings: var(--default-monoFont-featureSettings, normal); /* 2 */
font-variation-settings: var(--default-monoFont-variationSettings, normal); /* 3 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Make lists unstyled by default.
*/
ol,
ul,
menu {
list-style: none;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/*
1. Inherit font styles in all browsers.
2. Remove border radius in all browsers.
3. Remove background color in all browsers.
4. Ensure consistent opacity for disabled states in all browsers.
*/
button,
input,
select,
optgroup,
textarea,
::file-selector-button {
font: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
border-radius: 0; /* 2 */
background-color: transparent; /* 3 */
opacity: 1; /* 4 */
}
/*
Restore default font weight.
*/
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
/*
Restore indentation.
*/
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
/*
Restore space after button.
*/
::file-selector-button {
margin-inline-end: 4px;
}
/*
Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
*/
::placeholder {
opacity: 1;
}
/*
Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not
crash when using `color-mix()` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)
*/
@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or
(contain-intrinsic-size: 1px) /* Safari 17+ */ {
::placeholder {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Ensure date/time inputs have the same height when empty in iOS Safari.
2. Ensure text alignment can be changed on date/time inputs in iOS Safari.
*/
::-webkit-date-and-time-value {
min-height: 1lh; /* 1 */
text-align: inherit; /* 2 */
}
/*
Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.
*/
::-webkit-datetime-edit {
display: inline-flex;
}
/*
Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.
*/
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit,
::-webkit-datetime-edit-year-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-minute-field,
::-webkit-datetime-edit-second-field,
::-webkit-datetime-edit-millisecond-field,
::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
/*
Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)
*/
::-webkit-calendar-picker-indicator {
line-height: 1;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Correct the inability to style the border radius in iOS Safari.
*/
button,
input:where([type='button'], [type='reset'], [type='submit']),
::file-selector-button {
appearance: button;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Make elements with the HTML hidden attribute stay hidden by default.
*/
[hidden]:where(:not([hidden~='until-found'])) {
display: none !important;
}
/* layer: preflights */
@keyframes wk-slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* layer: shortcuts */
.label-text{font-size:var(--text-sm-fontSize);line-height:var(--un-leading, var(--text-sm-lineHeight));color:color-mix(in srgb, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;--un-font-weight:var(--fontWeight-medium);font-weight:var(--fontWeight-medium);margin-bottom:calc(var(--spacing) * 1.5);display:block;}
.textarea{font-size:var(--text-sm-fontSize);line-height:var(--un-leading, var(--text-sm-lineHeight));font-family:var(--font-sans);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:color-mix(in srgb, var(--colors-border) var(--un-border-opacity), transparent) /* #e4e4e7 */;background-color:color-mix(in srgb, var(--colors-bg) var(--un-bg-opacity), transparent) /* #fafafa */;width:100%;resize:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,--un-gradient-from,--un-gradient-via,--un-gradient-to;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}
.textarea::placeholder{color:color-mix(in srgb, var(--colors-muted) 60%, transparent) /* #71717a */;}
.textarea:focus{--un-outline-style:none;outline-style:none;border-color:color-mix(in srgb, var(--colors-muted) var(--un-border-opacity), transparent) /* #71717a */;}
.container{width:100%;}
@supports (color: color-mix(in lab, red, red)){
.label-text{color:color-mix(in oklab, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;}
.textarea::placeholder{color:color-mix(in oklab, var(--colors-muted) 60%, transparent) /* #71717a */;}
.textarea{border-color:color-mix(in oklab, var(--colors-border) var(--un-border-opacity), transparent) /* #e4e4e7 */;background-color:color-mix(in oklab, var(--colors-bg) var(--un-bg-opacity), transparent) /* #fafafa */;}
.textarea:focus{border-color:color-mix(in oklab, var(--colors-muted) var(--un-border-opacity), transparent) /* #71717a */;}
}
@media (min-width: 40rem){
.container{max-width:40rem;}
}
@media (min-width: 48rem){
.container{max-width:48rem;}
}
@media (min-width: 64rem){
.container{max-width:64rem;}
}
@media (min-width: 80rem){
.container{max-width:80rem;}
}
@media (min-width: 96rem){
.container{max-width:96rem;}
}
/* layer: default */
.text-lg{font-size:var(--text-lg-fontSize);line-height:var(--un-leading, var(--text-lg-lineHeight));}
.text-sm{font-size:var(--text-sm-fontSize);line-height:var(--un-leading, var(--text-sm-lineHeight));}
.text-xs{font-size:var(--text-xs-fontSize);line-height:var(--un-leading, var(--text-xs-lineHeight));}
.text-accent{color:color-mix(in srgb, var(--colors-accent) var(--un-text-opacity), transparent) /* #10b981 */;}
.text-danger{color:color-mix(in srgb, var(--colors-danger) var(--un-text-opacity), transparent) /* #ef4444 */;}
.text-muted{color:color-mix(in srgb, var(--colors-muted) var(--un-text-opacity), transparent) /* #71717a */;}
.text-text{color:color-mix(in srgb, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;}
.text-white{color:color-mix(in srgb, var(--colors-white) var(--un-text-opacity), transparent) /* #fff */;}
.hover\:text-text:hover{color:color-mix(in srgb, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;}
.tracking-wide{--un-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);}
.font-medium{--un-font-weight:var(--fontWeight-medium);font-weight:var(--fontWeight-medium);}
.font-semibold{--un-font-weight:var(--fontWeight-semibold);font-weight:var(--fontWeight-semibold);}
.m-0{margin:calc(var(--spacing) * 0);}
.-mx-5{margin-inline:calc(calc(var(--spacing) * 5) * -1);}
.mx-5{margin-inline:calc(var(--spacing) * 5);}
.mb-4{margin-bottom:calc(var(--spacing) * 4);}
.mb-6{margin-bottom:calc(var(--spacing) * 6);}
.mt-4{margin-top:calc(var(--spacing) * 4);}
.p-0\.5{padding:calc(var(--spacing) * 0.5);}
.p-1{padding:calc(var(--spacing) * 1);}
.p-3{padding:calc(var(--spacing) * 3);}
.p-5{padding:calc(var(--spacing) * 5);}
.px-5{padding-inline:calc(var(--spacing) * 5);}
.px-6{padding-inline:calc(var(--spacing) * 6);}
.py-10{padding-block:calc(var(--spacing) * 10);}
.py-4{padding-block:calc(var(--spacing) * 4);}
.text-center{text-align:center;}
.border{border-width:1px;}
.border-b{border-bottom-width:1px;}
.border-t{border-top-width:1px;}
.border-border{border-color:color-mix(in srgb, var(--colors-border) var(--un-border-opacity), transparent) /* #e4e4e7 */;}
.rounded-full{border-radius:calc(infinity * 1px);}
.rounded-lg{border-radius:var(--radius-lg);}
.border-none{--un-border-style:none;border-style:none;}
.bg-black\/40{background-color:color-mix(in srgb, var(--colors-black) 40%, transparent) /* #000 */;}
.bg-black\/60{background-color:color-mix(in srgb, var(--colors-black) 60%, transparent) /* #000 */;}
.bg-red-50{background-color:color-mix(in srgb, var(--colors-red-50) var(--un-bg-opacity), transparent) /* oklch(97.1% 0.013 17.38) */;}
.bg-text{background-color:color-mix(in srgb, var(--colors-text) var(--un-bg-opacity), transparent) /* #18181b */;}
.bg-transparent{background-color:transparent;}
.bg-white{background-color:color-mix(in srgb, var(--colors-white) var(--un-bg-opacity), transparent) /* #fff */;}
.hover\:bg-zinc-700:hover{background-color:color-mix(in srgb, var(--colors-zinc-700) var(--un-bg-opacity), transparent) /* oklch(37% 0.013 285.805) */;}
.flex{display:flex;}
.flex-1{flex:1 1 0%;}
.gap-2{gap:calc(var(--spacing) * 2);}
.gap-3{gap:calc(var(--spacing) * 3);}
.h-11{height:calc(var(--spacing) * 11);}
.h-12{height:calc(var(--spacing) * 12);}
.h-5{height:calc(var(--spacing) * 5);}
.h-full{height:100%;}
.max-w-100{max-width:calc(var(--spacing) * 100);}
.min-h-20{min-height:calc(var(--spacing) * 20);}
.w-11{width:calc(var(--spacing) * 11);}
.w-12{width:calc(var(--spacing) * 12);}
.w-5{width:calc(var(--spacing) * 5);}
.w-full{width:100%;}
.block{display:block;}
.visible{visibility:visible;}
.cursor-pointer{cursor:pointer;}
.pointer-events-auto{pointer-events:auto;}
.resize-y{resize:vertical;}
.uppercase{text-transform:uppercase;}
.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;}
.shadow{--un-shadow:0 1px 3px 0 var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-lg{--un-shadow:0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-xl{--un-shadow:0 20px 25px -5px var(--un-shadow-color, rgb(0 0 0 / 0.1)),0 8px 10px -6px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-inset-shadow), var(--un-inset-ring-shadow), var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.hover\:scale-105:hover{--un-scale-x:105%;--un-scale-y:105%;scale:var(--un-scale-x) var(--un-scale-y);}
.transition-all{transition-property:all;transition-timing-function:var(--un-ease, var(--default-transition-timingFunction));transition-duration:var(--un-duration, var(--default-transition-duration));}
.items-center{align-items:center;}
.inset-0{inset:calc(var(--spacing) * 0);}
.bottom-0{bottom:calc(var(--spacing) * 0);}
.bottom-5{bottom:calc(var(--spacing) * 5);}
.left-5{left:calc(var(--spacing) * 5);}
.top-0{top:calc(var(--spacing) * 0);}
.justify-start{justify-content:flex-start;}
.justify-center{justify-content:center;}
.justify-between{justify-content:space-between;}
.fixed{position:fixed;}
.sticky{position:sticky;}
.z-\[100001\]{z-index:100001;}
.z-100000{z-index:100000;}
.z-99999{z-index:99999;}
.overflow-y-auto{overflow-y:auto;}
@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
.animate-\[wk-slide-in_0\.2s_ease-out\]{animation:wk-slide-in 0.2s ease-out;}
.animate-spin{animation:spin 1s linear infinite;}
.grayscale{--un-grayscale:grayscale(100%);filter:var(--un-blur,) var(--un-brightness,) var(--un-contrast,) var(--un-grayscale,) var(--un-hue-rotate,) var(--un-invert,) var(--un-saturate,) var(--un-sepia,) var(--un-drop-shadow,);}
@supports (color: color-mix(in lab, red, red)){
.text-accent{color:color-mix(in oklab, var(--colors-accent) var(--un-text-opacity), transparent) /* #10b981 */;}
.text-danger{color:color-mix(in oklab, var(--colors-danger) var(--un-text-opacity), transparent) /* #ef4444 */;}
.text-muted{color:color-mix(in oklab, var(--colors-muted) var(--un-text-opacity), transparent) /* #71717a */;}
.text-text{color:color-mix(in oklab, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;}
.text-white{color:color-mix(in oklab, var(--colors-white) var(--un-text-opacity), transparent) /* #fff */;}
.hover\:text-text:hover{color:color-mix(in oklab, var(--colors-text) var(--un-text-opacity), transparent) /* #18181b */;}
.border-border{border-color:color-mix(in oklab, var(--colors-border) var(--un-border-opacity), transparent) /* #e4e4e7 */;}
.bg-black\/40{background-color:color-mix(in oklab, var(--colors-black) 40%, transparent) /* #000 */;}
.bg-black\/60{background-color:color-mix(in oklab, var(--colors-black) 60%, transparent) /* #000 */;}
.bg-red-50{background-color:color-mix(in oklab, var(--colors-red-50) var(--un-bg-opacity), transparent) /* oklch(97.1% 0.013 17.38) */;}
.bg-text{background-color:color-mix(in oklab, var(--colors-text) var(--un-bg-opacity), transparent) /* #18181b */;}
.bg-white{background-color:color-mix(in oklab, var(--colors-white) var(--un-bg-opacity), transparent) /* #fff */;}
.hover\:bg-zinc-700:hover{background-color:color-mix(in oklab, var(--colors-zinc-700) var(--un-bg-opacity), transparent) /* oklch(37% 0.013 285.805) */;}
}

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View file

@ -0,0 +1,31 @@
import { defineConfig, presetWind4, presetIcons } from 'unocss'
import { theme, shortcuts } from '@writekit/ui/uno.config'
export default defineConfig({
presets: [
presetWind4(),
presetIcons({
scale: 1.2,
cdn: 'https://esm.sh/',
extraProperties: {
'display': 'inline-block',
'vertical-align': 'middle',
},
}),
],
theme,
shortcuts: {
...shortcuts,
'label-text': 'block text-sm font-medium text-text mb-1.5',
},
preflights: [
{
getCSS: () => `
@keyframes wk-slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
`,
},
],
})

View file

@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import { resolve } from 'path'
export default defineConfig(({ command }) => ({
plugins: [
// Only use UnoCSS plugin in dev mode for HMR
...(command === 'serve' ? [UnoCSS()] : []),
...(command === 'build' ? [react()] : []),
],
base: command === 'serve' ? '/@owner-tools' : '/',
esbuild: {
jsxInject: `import React from 'react'`
},
resolve: {
alias: command === 'serve' ? {
// In dev mode, alias the generated file to virtual:uno.css
'./uno-generated.css': 'virtual:uno.css',
} : {},
},
...(command === 'serve' && {
server: {
host: true,
port: 5174,
strictPort: true,
allowedHosts: true,
hmr: {
clientPort: 80,
path: '/@owner-tools'
},
proxy: {
'/api/studio': {
target: 'http://app:8080',
changeOrigin: true
}
}
}
}),
build: {
lib: {
entry: resolve(__dirname, 'src/main.tsx'),
name: 'OwnerTools',
formats: ['iife'],
fileName: () => 'owner-tools.js'
},
outDir: '../../internal/tenant/assets/js',
emptyOutDir: false,
rollupOptions: {
output: {
intro: 'const process = { env: { NODE_ENV: "production" } };'
}
}
}
}))

4428
frontends/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

14
frontends/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "writekit-frontends",
"private": true,
"workspaces": [
"ui",
"studio",
"owner-tools"
],
"scripts": {
"dev:studio": "npm -w studio run dev",
"dev:owner-tools": "npm -w owner-tools run dev",
"build": "npm -w ui run build && npm -w studio run build && npm -w owner-tools run build"
}
}

View file

@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@writekit/ui": "*",
"@iconify-json/logos": "^1.2.10", "@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82", "@iconify-json/lucide": "^1.2.82",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",

View file

@ -7,17 +7,14 @@ import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table' import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import TaskList from '@tiptap/extension-task-list' import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' import TaskItem from '@tiptap/extension-task-item'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { Markdown } from '@tiptap/markdown' import { Markdown } from '@tiptap/markdown'
import { common, createLowlight } from 'lowlight'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $editorPost, broadcastPreview } from '../../stores/editor' import { $editorPost, broadcastPreview } from '../../stores/editor'
import { $settings } from '../../stores/settings' import { $settings } from '../../stores/settings'
import { Icons } from '../shared/Icons' import { Icons } from '../shared/Icons'
import { SlashCommands } from './SlashCommands' import { SlashCommands } from './SlashCommands'
import { CodeBlockEnhanced } from './extensions/code-block'
const lowlight = createLowlight(common)
interface PostEditorProps { interface PostEditorProps {
onChange?: (markdown: string) => void onChange?: (markdown: string) => void
@ -45,7 +42,6 @@ async function uploadImage(file: File): Promise<string | null> {
export function PostEditor({ onChange }: PostEditorProps) { export function PostEditor({ onChange }: PostEditorProps) {
const post = useStore($editorPost) const post = useStore($editorPost)
const settings = useStore($settings) const settings = useStore($settings)
const isInitialMount = useRef(true)
const skipNextUpdate = useRef(false) const skipNextUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
@ -79,9 +75,7 @@ export function PostEditor({ onChange }: PostEditorProps) {
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
CodeBlockLowlight.configure({ CodeBlockEnhanced,
lowlight,
}),
Markdown, Markdown,
SlashCommands, SlashCommands,
], ],
@ -184,11 +178,6 @@ export function PostEditor({ onChange }: PostEditorProps) {
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) return
if (isInitialMount.current) {
isInitialMount.current = false
return
}
const currentContent = editor.getMarkdown() const currentContent = editor.getMarkdown()
if (currentContent !== post.content) { if (currentContent !== post.content) {
skipNextUpdate.current = true skipNextUpdate.current = true

View file

@ -0,0 +1,104 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { common, createLowlight } from 'lowlight'
import { CodeBlockView } from './CodeBlockView'
import type { MarkdownToken, JSONContent, MarkdownRendererHelpers, RenderContext, MarkdownParseHelpers } from '@tiptap/core'
export const lowlight = createLowlight(common)
function parseInfoString(info: string): { language: string | null; title: string | null } {
const trimmed = info.trim()
const titleMatch = trimmed.match(/title=["']([^"']+)["']/)
const title = titleMatch ? titleMatch[1] : null
const languagePart = trimmed.replace(/title=["'][^"']+["']/, '').trim()
const language = languagePart || null
return { language, title }
}
function buildInfoString(language: string | null, title: string | null): string {
const parts: string[] = []
if (language) parts.push(language)
if (title) parts.push(`title="${title}"`)
return parts.join(' ')
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
codeBlockEnhanced: {
setCodeBlock: (attributes?: { language?: string; title?: string }) => ReturnType
}
}
}
export const CodeBlockEnhanced = CodeBlockLowlight.extend({
name: 'codeBlock',
addAttributes() {
return {
...this.parent?.(),
language: {
default: null,
parseHTML: (element) => {
const classAttr = element.querySelector('code')?.getAttribute('class')
if (classAttr) {
const match = classAttr.match(/language-(\w+)/)
return match ? match[1] : null
}
return null
},
},
title: {
default: null,
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes) => {
if (!attributes.title) return {}
return { 'data-title': attributes.title }
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: ({ editor }) => {
if (!editor.isActive('codeBlock')) return false
editor.commands.insertContent(' ')
return true
},
'Shift-Tab': () => {
return true
},
}
},
markdownTokenName: 'code',
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
if (token.raw?.startsWith('```') === false && token.codeBlockStyle !== 'indented') {
return []
}
const info = (token.lang as string) || ''
const { language, title } = parseInfoString(info)
const text = (token.text as string) || ''
return helpers.createNode(
'codeBlock',
{ language, title },
text ? [helpers.createTextNode(text)] : []
)
},
renderMarkdown(node: JSONContent, helpers: MarkdownRendererHelpers, _ctx: RenderContext) {
const language = node.attrs?.language || ''
const title = node.attrs?.title || null
const info = buildInfoString(language, title)
const content = node.content ? helpers.renderChildren(node.content) : ''
return '```' + info + '\n' + content + '\n```'
},
}).configure({
lowlight,
})

View file

@ -0,0 +1,170 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES } from './icons'
import { Icons } from '../../../shared/Icons'
export function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
const { language, title } = node.attrs
const [showLanguageMenu, setShowLanguageMenu] = useState(false)
const [copied, setCopied] = useState(false)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState(title || '')
const titleInputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const iconUrl = language ? getLanguageIconUrl(language) : null
const displayName = language ? getLanguageDisplayName(language) : 'Plain text'
const handleCopy = useCallback(async () => {
const code = node.textContent
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [node.textContent])
const handleLanguageSelect = useCallback((lang: string) => {
updateAttributes({ language: lang })
setShowLanguageMenu(false)
}, [updateAttributes])
const handleTitleSubmit = useCallback(() => {
updateAttributes({ title: titleValue || null })
setIsEditingTitle(false)
}, [titleValue, updateAttributes])
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleTitleSubmit()
}
if (e.key === 'Escape') {
setTitleValue(title || '')
setIsEditingTitle(false)
}
}, [handleTitleSubmit, title])
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.select()
}
}, [isEditingTitle])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowLanguageMenu(false)
}
}
if (showLanguageMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showLanguageMenu])
return (
<NodeViewWrapper className="code-block-wrapper relative my-6">
{/* Header bar */}
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-50 border border-zinc-200 border-b-0 rounded-t-md">
{/* Language selector */}
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setShowLanguageMenu(!showLanguageMenu)}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{iconUrl && (
<img src={iconUrl} alt="" className="w-3.5 h-3.5" />
)}
<span>{displayName}</span>
<Icons.ChevronDown className="w-3 h-3 opacity-50" />
</button>
{showLanguageMenu && (
<div className="absolute top-full left-0 mt-1 w-48 max-h-64 overflow-y-auto bg-white border border-zinc-200 rounded-md shadow-lg z-50">
{SUPPORTED_LANGUAGES.map((lang) => {
const langIcon = getLanguageIconUrl(lang)
return (
<button
key={lang}
type="button"
onClick={() => handleLanguageSelect(lang)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-zinc-50 transition-colors ${
language === lang ? 'bg-zinc-100 text-zinc-900' : 'text-zinc-600'
}`}
>
{langIcon && <img src={langIcon} alt="" className="w-3.5 h-3.5" />}
<span>{getLanguageDisplayName(lang)}</span>
</button>
)
})}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-4 bg-zinc-200" />
{/* Title (editable) */}
<div className="flex-1 min-w-0">
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={handleTitleKeyDown}
placeholder="filename.ext"
className="w-full px-1 py-0.5 text-xs bg-white border border-zinc-300 rounded focus:outline-none focus:border-zinc-400"
contentEditable={false}
/>
) : (
<button
type="button"
onClick={() => {
setTitleValue(title || '')
setIsEditingTitle(true)
}}
className="text-xs text-zinc-500 hover:text-zinc-700 truncate max-w-full"
contentEditable={false}
>
{title || 'Add title...'}
</button>
)}
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{copied ? (
<>
<Icons.Check className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600">Copied</span>
</>
) : (
<>
<Icons.Copy className="w-3 h-3" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Code content */}
<pre className="!mt-0 !rounded-t-none">
<code className={language ? `language-${language}` : ''}>
<NodeViewContent />
</code>
</pre>
</NodeViewWrapper>
)
}

View file

@ -0,0 +1,174 @@
/**
* Language to Iconify icon mapping
* Uses simple-icons set via Iconify API
* Format: https://api.iconify.design/simple-icons/{slug}.svg
*/
export const LANGUAGE_ICONS: Record<string, string> = {
// Web
javascript: 'javascript',
js: 'javascript',
typescript: 'typescript',
ts: 'typescript',
html: 'html5',
css: 'css3',
scss: 'sass',
sass: 'sass',
less: 'less',
// Frontend frameworks
react: 'react',
jsx: 'react',
tsx: 'react',
vue: 'vuedotjs',
svelte: 'svelte',
angular: 'angular',
astro: 'astro',
// Backend
python: 'python',
py: 'python',
go: 'go',
golang: 'go',
rust: 'rust',
java: 'openjdk',
kotlin: 'kotlin',
scala: 'scala',
ruby: 'ruby',
rb: 'ruby',
php: 'php',
csharp: 'csharp',
cs: 'csharp',
cpp: 'cplusplus',
c: 'c',
swift: 'swift',
// Data & config
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
xml: 'xml',
// Shell & scripting
bash: 'gnubash',
sh: 'gnubash',
shell: 'gnubash',
zsh: 'gnubash',
powershell: 'powershell',
ps1: 'powershell',
// Database
sql: 'postgresql',
mysql: 'mysql',
postgres: 'postgresql',
postgresql: 'postgresql',
mongodb: 'mongodb',
redis: 'redis',
// Other
graphql: 'graphql',
gql: 'graphql',
docker: 'docker',
dockerfile: 'docker',
markdown: 'markdown',
md: 'markdown',
lua: 'lua',
elixir: 'elixir',
erlang: 'erlang',
haskell: 'haskell',
clojure: 'clojure',
zig: 'zig',
nim: 'nim',
r: 'r',
julia: 'julia',
dart: 'dart',
flutter: 'flutter',
solidity: 'solidity',
terraform: 'terraform',
nginx: 'nginx',
apache: 'apache',
}
/**
* Get Iconify API URL for a language
*/
export function getLanguageIconUrl(language: string): string | null {
const slug = LANGUAGE_ICONS[language.toLowerCase()]
if (!slug) return null
return `https://api.iconify.design/simple-icons/${slug}.svg?color=%2371717a`
}
/**
* Get display name for a language
*/
export function getLanguageDisplayName(language: string): string {
const displayNames: Record<string, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
python: 'Python',
go: 'Go',
rust: 'Rust',
java: 'Java',
kotlin: 'Kotlin',
ruby: 'Ruby',
php: 'PHP',
csharp: 'C#',
cpp: 'C++',
c: 'C',
swift: 'Swift',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
json: 'JSON',
yaml: 'YAML',
bash: 'Bash',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
docker: 'Docker',
jsx: 'JSX',
tsx: 'TSX',
vue: 'Vue',
svelte: 'Svelte',
}
return displayNames[language.toLowerCase()] || language
}
/**
* All supported languages for the language selector
*/
export const SUPPORTED_LANGUAGES = [
'javascript',
'typescript',
'python',
'go',
'rust',
'java',
'kotlin',
'ruby',
'php',
'csharp',
'cpp',
'c',
'swift',
'html',
'css',
'scss',
'json',
'yaml',
'bash',
'sql',
'graphql',
'markdown',
'docker',
'jsx',
'tsx',
'vue',
'svelte',
'lua',
'elixir',
'haskell',
'dart',
'terraform',
] as const

View file

@ -0,0 +1,3 @@
export { CodeBlockEnhanced } from './CodeBlockExtension'
export { CodeBlockView } from './CodeBlockView'
export { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES, LANGUAGE_ICONS } from './icons'

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $toasts, removeToast } from '../../stores/app' import { $toasts, removeToast } from '../../stores/app'
import { Icons } from '../shared/Icons' import { Icons } from '@writekit/ui'
export function Toasts() { export function Toasts() {
const toasts = useStore($toasts) const toasts = useStore($toasts)

View file

@ -0,0 +1,18 @@
export {
Button,
Input,
Textarea,
Select,
Icons,
Tabs,
type Tab,
Modal,
Dropdown,
type DropdownOption,
Toggle,
Badge,
ActionMenu,
type ActionMenuItem,
} from '@writekit/ui'
export { Toasts } from './Toast'
export { UsageIndicator } from './UsageIndicator'

View file

@ -1,9 +1,8 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings' import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
import { addToast } from '../stores/app' import { addToast } from '../stores/app'
import { SaveBar, DesignPageSkeleton } from '../components/shared' import { SaveBar, DesignPageSkeleton } from '../components/shared'
import './DesignPage.preview.css'
const fontConfigs = { const fontConfigs = {
system: { family: 'system-ui, -apple-system, sans-serif', url: '' }, system: { family: 'system-ui, -apple-system, sans-serif', url: '' },
@ -139,104 +138,13 @@ function LayoutPreview({ layout, selected }: { layout: string; selected: boolean
) )
} }
function PreviewCodeBlock({ theme }: { theme: string }) {
const colors = getThemeColors(theme)
return (
<div className="preview-code" style={{ background: colors.bg, color: colors.text }}>
<div><span style={{ color: colors.keyword }}>const</span> api = <span style={{ color: colors.keyword }}>await</span> fetch(<span style={{ color: colors.string }}>'/posts'</span>)</div>
</div>
)
}
function PreviewPostCard() {
return (
<div className="preview-post-card">
<div className="preview-date">Jan 15, 2024</div>
<h3 className="preview-title">Building APIs</h3>
<p className="preview-description">A deep dive into REST patterns and best practices.</p>
</div>
)
}
function LivePreview({ settings }: { settings: Record<string, string> }) {
const fontKey = settings.font || 'system'
const fontConfig = fontConfigs[fontKey as keyof typeof fontConfigs] || fontConfigs.system
const codeTheme = settings.code_theme || 'github'
const accent = settings.accent_color || '#10b981'
const layout = settings.layout || 'default'
const compactness = settings.compactness || 'cozy'
useFontLoader(fontKey)
return (
<div
className={`blog-preview layout-${layout} compactness-${compactness} border border-border`}
style={{ '--accent': accent, '--font-body': fontConfig.family } as React.CSSProperties}
>
{/* Browser chrome */}
<div className="preview-chrome">
<div className="preview-chrome-dots">
<div className="preview-chrome-dot red" />
<div className="preview-chrome-dot yellow" />
<div className="preview-chrome-dot green" />
</div>
<span className="preview-chrome-url">yourblog.writekit.dev</span>
</div>
{/* Header */}
<header className="preview-header">
<span className="preview-site-name">Your Blog</span>
<nav className="preview-nav">
<a href="#">Posts</a>
<span>About</span>
</nav>
</header>
{/* Content - varies by layout */}
{layout === 'magazine' ? (
<div className="preview-posts">
<PreviewPostCard />
<PreviewPostCard />
</div>
) : (
<div className="preview-content">
<div className="preview-date">Jan 15, 2024</div>
<h3 className="preview-title">Building Better APIs</h3>
<p className="preview-description">
A deep dive into REST design patterns and best practices for modern web development.
</p>
<div className="preview-prose">
<PreviewCodeBlock theme={codeTheme} />
</div>
<div className="preview-tags">
<span className="preview-tag">typescript</span>
<span className="preview-tag">react</span>
</div>
</div>
)}
{/* Footer */}
<footer className="preview-footer">
&copy; 2024 Your Blog
</footer>
</div>
)
}
export default function DesignPage() { export default function DesignPage() {
const settings = useStore($settings) const settings = useStore($settings)
const { data } = useStore($settingsData) const { data } = useStore($settingsData)
const hasChanges = useStore($hasChanges) const hasChanges = useStore($hasChanges)
const changedFields = useStore($changedFields) const changedFields = useStore($changedFields)
const saveSettings = useStore($saveSettings) const saveSettings = useStore($saveSettings)
const [availableThemes, setAvailableThemes] = useState<string[]>(Object.keys(themePreviewColors)) const availableThemes = Object.keys(themePreviewColors)
useEffect(() => {
fetch('/api/studio/code-themes')
.then(r => r.json())
.then((themes: string[]) => setAvailableThemes(themes))
.catch(() => {})
}, [])
// Load all fonts for previews // Load all fonts for previews
Object.keys(fontConfigs).forEach(useFontLoader) Object.keys(fontConfigs).forEach(useFontLoader)
@ -261,47 +169,6 @@ export default function DesignPage() {
{/* Panel container - full-bleed borders */} {/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10"> <div className="-mx-6 lg:-mx-10">
{/* Live Preview */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Live Preview</div>
</div>
<div className="px-6 lg:px-10 py-6">
<LivePreview settings={settings as Record<string, string>} />
</div>
<div className="border-t border-border" />
{/* Presets */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Quick Presets</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ name: 'Developer', desc: 'Monospace, dark code, minimal', font: 'jetbrains-mono', code_theme: 'onedark', layout: 'minimal', compactness: 'compact', accent_color: '#10b981' },
{ name: 'Writer', desc: 'Serif, light code, spacious', font: 'merriweather', code_theme: 'github', layout: 'default', compactness: 'spacious', accent_color: '#6366f1' },
{ name: 'Magazine', desc: 'Sans-serif, grid layout', font: 'inter', code_theme: 'nord', layout: 'magazine', compactness: 'cozy', accent_color: '#f59e0b' },
] as const).map(preset => (
<button
key={preset.name}
onClick={() => {
$settings.setKey('font', preset.font)
$settings.setKey('code_theme', preset.code_theme)
$settings.setKey('layout', preset.layout)
$settings.setKey('compactness', preset.compactness)
$settings.setKey('accent_color', preset.accent_color)
}}
className="p-4 border border-border text-left hover:border-accent hover:bg-accent/5 transition-colors"
>
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted mt-1">{preset.desc}</div>
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Accent Color */} {/* Accent Color */}
<div className="px-6 lg:px-10 py-5"> <div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div> <div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div>

View file

@ -1,4 +1,5 @@
import { defineConfig, presetWind4, presetIcons, presetTypography } from 'unocss' import { defineConfig, presetWind4, presetIcons, presetTypography } from 'unocss'
import { theme, shortcuts } from '@writekit/ui/uno.config'
export default defineConfig({ export default defineConfig({
presets: [ presets: [
@ -206,54 +207,6 @@ export default defineConfig({
`, `,
}, },
], ],
theme: { theme,
colors: { shortcuts,
bg: '#fafafa',
surface: '#ffffff',
text: '#0a0a0a',
muted: '#737373',
border: '#e5e5e5',
accent: '#10b981',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
},
fontFamily: {
sans: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace',
},
},
shortcuts: {
// Buttons - dark bg with light text for primary
'btn': 'inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium border border-border transition-all duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
'btn-primary': 'btn bg-text text-bg border-text hover:bg-[#1a1a1a]',
'btn-secondary': 'btn bg-bg text-text hover:border-muted',
'btn-danger': 'btn text-danger border-danger hover:bg-danger hover:text-white hover:border-danger',
'btn-ghost': 'btn border-transparent hover:bg-border',
// Inputs - clean borders, minimal styling
'input': 'w-full px-3 py-2 text-sm bg-bg border border-border font-sans focus:outline-none focus:border-muted transition-colors placeholder:text-muted/60',
'textarea': 'input resize-none',
// Labels - small, muted
'label': 'block text-xs text-muted font-medium mb-1',
// Cards - minimal borders, no shadows
'card': 'bg-surface border border-border p-6',
'section': 'card',
// Navigation - matching old dashboard
'nav-item': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-muted transition-all duration-150 hover:text-text hover:bg-black/4',
'nav-item-active': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-text bg-black/4 before:content-[""] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-[3px] before:h-4 before:bg-accent before:rounded-r-sm',
'nav-section': 'text-[10px] uppercase tracking-[0.06em] text-muted font-semibold px-2.5 pt-3 pb-1.5',
// Badges
'badge': 'inline-flex items-center text-xs px-2 py-0.5 border',
'badge-draft': 'badge text-warning border-warning/40 bg-warning/10',
'badge-published': 'badge text-success border-success/40 bg-success/10',
// Page structure
'page-header': 'flex items-center justify-between mb-6',
'page-title': 'text-base font-medium text-text',
},
}) })

27
frontends/ui/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@writekit/ui",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./uno.config": "./uno.config.ts",
"./styles": "./src/styles.ts"
},
"scripts": {
"build": "echo 'no build needed - consumed as source'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"unocss": "^66.5.12"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.82",
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.7.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
}

Some files were not shown because too many files have changed in this diff Show more