Use the brutalist portal: OAuth (recommended) or manual token (fallback).
@@ -99,7 +101,8 @@ export default function TimelinePage(props: { mode: 'home' | 'local' | 'federate
+
+
{(q.error as Error).message}
) : null}
diff --git a/apps/web/src/stages/stages.ts b/apps/web/src/stages/stages.ts
index 5f00dcb..af4ad62 100644
--- a/apps/web/src/stages/stages.ts
+++ b/apps/web/src/stages/stages.ts
@@ -9,7 +9,12 @@ export type Stage = {
export const STAGES: Stage[] = [
{ id: 1, title: 'Bootstrap monorepo (React + UI + Core)', status: 'done' },
- { id: 2, title: 'PC-first 3-pane layout shell', status: 'done' },
+ {
+ id: 2,
+ title: 'PC-first 3-pane layout shell',
+ status: 'done',
+ notes: 'Resizable left/right panes with collapsible toggles'
+ },
{
id: 3,
title: 'Connect (manual token) + session persistence',
@@ -19,8 +24,8 @@ export const STAGES: Stage[] = [
{
id: 4,
title: 'OAuth PKCE (web + desktop) flow',
- status: 'todo',
- notes: 'PKCE helpers included; full flow staged'
+ status: 'wip',
+ notes: 'Web OAuth flow wired; desktop wiring pending'
},
{ id: 5, title: 'Timelines (home/local/federated)', status: 'done' },
{ id: 6, title: 'Thread inspector (status + context)', status: 'done' },
diff --git a/apps/web/src/styles/index.css b/apps/web/src/styles/index.css
index d185b48..b6b7cde 100644
--- a/apps/web/src/styles/index.css
+++ b/apps/web/src/styles/index.css
@@ -260,6 +260,73 @@ html.ui-brutal-v3 .ghost-pane-header {
box-shadow: var(--g-shadow-md) var(--g-shadow-md) 0 rgba(var(--g-accent), 0.10);
}
+html.theme-brutal .ghost-card,
+html.theme-brutal .ghost-status,
+html.theme-brutal .ghost-navitem,
+html.theme-brutal .ghost-trends-item {
+ position: relative;
+ overflow: hidden;
+ background:
+ linear-gradient(135deg, rgba(var(--g-accent),0.10), transparent 60%),
+ rgba(5,5,5,0.55);
+ border-color: rgba(var(--g-accent),0.34);
+ box-shadow:
+ 0 0 0 1px rgba(0,0,0,0.8) inset,
+ var(--g-shadow-md) var(--g-shadow-md) 0 rgba(var(--g-accent), 0.14);
+}
+
+html.theme-brutal .ghost-card::before,
+html.theme-brutal .ghost-status::before,
+html.theme-brutal .ghost-navitem::before,
+html.theme-brutal .ghost-trends-item::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ linear-gradient(0deg, rgba(255,255,255,0.04), transparent 45%),
+ radial-gradient(circle, rgba(255,255,255,0.12) 1px, transparent 1.4px);
+ background-size: 100% 100%, 18px 18px;
+ mix-blend-mode: screen;
+ opacity: 0.35;
+}
+
+html.theme-brutal .ghost-card::after,
+html.theme-brutal .ghost-status::after,
+html.theme-brutal .ghost-navitem::after,
+html.theme-brutal .ghost-trends-item::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ box-shadow: inset 0 0 0 1px rgba(var(--g-accent-2), 0.25);
+ opacity: 0.55;
+}
+
+html.theme-brutal .ghost-btn,
+html.theme-brutal .ghost-input {
+ position: relative;
+ overflow: hidden;
+ background:
+ linear-gradient(120deg, rgba(var(--g-accent),0.18), rgba(0,0,0,0.1) 60%),
+ rgba(0,0,0,0.45);
+ border-color: rgba(var(--g-accent),0.48);
+ box-shadow:
+ 0 0 0 1px rgba(0,0,0,0.75) inset,
+ var(--g-shadow-sm) var(--g-shadow-sm) 0 rgba(var(--g-accent), 0.18);
+}
+
+html.theme-brutal .ghost-btn::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ radial-gradient(circle, rgba(255,255,255,0.12) 1px, transparent 1.4px);
+ background-size: 16px 16px;
+ opacity: 0.25;
+}
+
.ghost-trends {
background:
linear-gradient(135deg, rgba(var(--g-accent),0.16), transparent 60%),
@@ -812,12 +879,12 @@ html.ui-brutal-v3 .ghost-pane-header {
/* Theme variants (applied to ) */
html.theme-brutal {
- --g-bg-0: #0a0f18;
- --g-bg-1: rgba(255,255,255,0.030);
- --g-bg-2: rgba(255,255,255,0.060);
- --g-accent: 74 144 226;
- --g-accent-2: 30 53 88;
- --g-border: rgba(74,144,226,0.18);
+ --g-bg-0: #070707;
+ --g-bg-1: rgba(255,255,255,0.035);
+ --g-bg-2: rgba(255,255,255,0.075);
+ --g-accent: 216 175 74;
+ --g-accent-2: 104 237 255;
+ --g-border: rgba(216,175,74,0.28);
--g-radius: 8px;
--g-cut: 14px;
@@ -890,10 +957,13 @@ html, body {
html.theme-brutal.ui-brutal-v3 .ghost-shell,
html.theme-corporate.ui-brutal-v3 .ghost-shell {
background:
- repeating-linear-gradient(0deg, rgba(74,144,226,0.10) 0, rgba(74,144,226,0.10) 1px, transparent 1px, transparent 64px),
- repeating-linear-gradient(90deg, rgba(74,144,226,0.08) 0, rgba(74,144,226,0.08) 1px, transparent 1px, transparent 64px),
- radial-gradient(900px 620px at 20% 18%, rgba(var(--g-accent), 0.18), transparent 60%),
+ radial-gradient(circle, rgba(var(--g-accent),0.18) 1px, transparent 1.2px),
+ radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1.3px),
+ linear-gradient(90deg, rgba(var(--g-accent),0.12) 0, rgba(var(--g-accent),0.12) 6px, transparent 6px),
+ radial-gradient(900px 620px at 20% 18%, rgba(var(--g-accent), 0.22), transparent 60%),
+ radial-gradient(520px 420px at 80% 20%, rgba(var(--g-accent-2), 0.16), transparent 65%),
linear-gradient(135deg, var(--g-bg-0), rgba(0,0,0,0.98));
+ background-size: 12px 12px, 18px 18px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
}
html.theme-candy.ui-brutal-v3 .ghost-shell {
@@ -933,6 +1003,15 @@ body::before {
linear-gradient(to bottom, rgba(255,255,255,0.04), transparent 25%, transparent 75%, rgba(0,0,0,0.25));
}
+html.theme-brutal body::before {
+ opacity: 0.12;
+ background-image:
+ radial-gradient(circle at 18% 20%, rgba(216,175,74,0.18), transparent 55%),
+ radial-gradient(circle at 80% 18%, rgba(104,237,255,0.12), transparent 60%),
+ radial-gradient(circle, rgba(255,255,255,0.10) 1px, transparent 1.4px);
+ background-size: 100% 100%, 100% 100%, 22px 22px;
+}
+
/* Optional noise overlay (toggle with .effects-noise on body) */
body.effects-noise::after {
content: "";
@@ -961,6 +1040,14 @@ body.effects-noise::after {
0 12px 40px rgba(0,0,0,0.35);
}
+html.theme-brutal .ghost-frame {
+ border: 1px solid rgba(216,175,74,0.38);
+ background: rgba(8,8,8,0.48);
+ box-shadow:
+ 0 0 0 1px rgba(0,0,0,0.75) inset,
+ var(--g-shadow-lg) var(--g-shadow-lg) 0 rgba(216,175,74,0.16);
+}
+
.ghost-frame::before {
content: "";
position: absolute;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 33a701d..b5b34ec 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -9,7 +9,8 @@ export default defineConfig({
preserveSymlinks: true
},
optimizeDeps: {
- include: ['@ghostodon/core', '@ghostodon/ui']
+ include: ['@ghostodon/core'],
+ exclude: ['@ghostodon/ui']
},
server: {
port: 5173,
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 0bb585f..cc8c508 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,6 +1,19 @@
export * from './primitives/Button';
export * from './primitives/Input';
+export * from './primitives/ActionPanel';
+export * from './primitives/AvatarButton';
+export * from './primitives/DateField';
+export * from './primitives/InfoCard';
+export * from './primitives/MediaCard';
+export * from './primitives/InputField';
+export * from './primitives/Skeleton';
+export * from './primitives/StatCard';
+export * from './primitives/TextAreaField';
+export * from './primitives/UploadButton';
+export * from './primitives/UserCard';
+export * from './primitives/Typography';
export * from './layout/Pane';
+export * from './layout/Layout';
export * from './layout/SplitShell';
export * from './mastodon/StatusCard';
export * from './util/cn';
diff --git a/packages/ui/src/layout/Layout.tsx b/packages/ui/src/layout/Layout.tsx
new file mode 100644
index 0000000..423dd97
--- /dev/null
+++ b/packages/ui/src/layout/Layout.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { cn } from '../util/cn';
+
+export function Container(props: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function Row(props: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
{props.children}
+ );
+}
+
+export function Column(props: {
+ children: React.ReactNode;
+ className?: string;
+ width?: number | string;
+ minWidth?: number | string;
+ maxWidth?: number | string;
+ resizable?: boolean;
+ collapsed?: boolean;
+}) {
+ const style: React.CSSProperties = {
+ width: props.width,
+ minWidth: props.minWidth,
+ maxWidth: props.maxWidth,
+ resize: props.resizable ? 'horizontal' : undefined,
+ };
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/packages/ui/src/mastodon/StatusCard.tsx b/packages/ui/src/mastodon/StatusCard.tsx
index 4e21ad5..46b287a 100644
--- a/packages/ui/src/mastodon/StatusCard.tsx
+++ b/packages/ui/src/mastodon/StatusCard.tsx
@@ -46,110 +46,144 @@ export function StatusCard(props: {
- {rebloggedBy ? (
-
- Boosted by @{rebloggedBy.acct}
-
- ) : null}
-
-
-