Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/>
<meta
name="description"
content="Stashed is a self-hosted, shared archive for saving ideas, discovering what others are thinking, and returning to useful thoughts later."
content="Good ideas deserve a second thought. Save, organize, and rediscover ideas with Stashed, a shared archive with Markdown, tags, search, and control over what you share."
/>
<script>
(() => {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"platejs": "53.3.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"remark-parse": "11.0.0",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/features/ideas/idea-detail-states.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from "../../components/ui/button.js";
import { buttonStyles } from "../../components/ui/button-variants.js";
import { twAnimatePulse } from "../../styles/common-styles.js";
import {
twIdeaBanner,
twIdeaReader,
twIdeaReaderContent,
twIdeaReaderDocument,
Expand All @@ -26,6 +27,7 @@ export function IdeaDetailSkeleton() {
<div className="h-11 w-28 rounded-control bg-surface-muted" />
<div className="h-11 w-24 rounded-control bg-surface-muted" />
</div>
<div className={twIdeaBanner} aria-hidden="true" />
<div className={twIdeaReaderLayout} aria-hidden="true">
<div className={twIdeaReaderDocument}>
<div className={`${twIdeaReaderHeading} space-y-4`}>
Expand Down
3 changes: 3 additions & 0 deletions src/features/ideas/idea-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { VisibilityIcon } from "./visibility-icon.js";
import { IdeaVisibilityEditor } from "./idea-visibility-editor.js";

import { IDEA_VISIBILITY_LABELS } from "./idea-visibility.js";
import { ProceduralIdeaBanner } from "./procedural-art/procedural-idea-banner.js";

const routeApi = getRouteApi("/ideas/$ideaId");
const dateFormatter = new Intl.DateTimeFormat(undefined, {
Expand Down Expand Up @@ -134,6 +135,8 @@ export function IdeaDetail() {
}
/>

<ProceduralIdeaBanner ideaId={idea.id} />

<div className={twIdeaReaderLayout}>
<div className={twIdeaReaderDocument}>
<header className={twIdeaReaderHeading}>
Expand Down
54 changes: 54 additions & 0 deletions src/features/ideas/procedural-art/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Procedural idea artwork · V2

V2 selects one complete curated composition, then varies parameters owned by that
composition: `idea.id → family → variant → geometry/details → scene primitives`.

`generateIdeaScene(idea.id)` selects a family and variant. Four family modules each
contain six named generators. `primitives.ts` only provides low-level SVG geometry
and shared generator types; the React component only renders the resulting scene.

| Family | Curated variants |
| --------- | ---------------------------------------------------------------------------------------------------------------- |
| Drafting | Construction circle, radial study, architectural section, offset grid, measurement diagram, technical projection |
| Contours | Single basin, dual peak, elongated ridge, valley, asymmetric mass, cropped mass |
| Editorial | Circle/rectangle, split study, diagonal study, asymmetric blocks, central void, overlapping study |
| Orbital | Concentric system, eccentric orbit, dual focus, sweeping trajectory, radial diagram, intersecting planes |

Every variant owns its anchors, dimensions, repetition, and annotation positions.
Measurements reference edges; orbit nodes lie on their trajectories; terrain rings
share a deformation model. Accents are limited to one small structural point;
contours use none. Most compositions span about 420–650 of the 1200 SVG units.
The same geometry is cropped on phones and recolored by existing semantic tokens.

Each family's first slot restores its original V1 reference composition from
commit `6a19079916fbf52475dd9d4bf217244881f1873a`: `construction-circle`,
`single-basin`, `circle-rectangle`, and `concentric-system`. They preserve V1's
parameter ranges, proportions, layering, stroke hierarchy, and registration marks,
but use V2 seeds. The orbital slot retains its stable name even though the restored
composition contains intersecting rotated ellipses and a quadratic sweep.

The original FNV-1a / Mulberry32 implementation and seed encoding are unchanged:

- `family:v2`
- `variant:v2:<family>`
- `geometry:v2:<family>:<variant>`
- `details:v2:<family>:<variant>`
- `accent:v2:<family>:<variant>`

The family order is `drafting`, `contours`, `editorial`, `orbital`; variant order
is shown in the table. Both are frozen. After V2 acceptance, do not append, remove,
or reorder slots, rename variants, change namespace meanings, or casually alter
generator math, RNG constants, seed encoding, or geometry helpers. These all affect
existing artwork. Substantial redesigns and new mappings require an intentional
V3. V2 replaces V1 artwork without database persistence or per-idea configuration.

Scene metadata is exposed as `data-art-version`, `data-art-family`, and
`data-art-variant`. The generic React renderer uses inline SVG with a fixed
`1200 × 240` viewBox, `xMidYMid slice`, non-scaling strokes, and decorative
accessibility (`aria-hidden`, `focusable="false"`). Theme changes only recolor
semantic tokens; screen size only changes cropping. Neither regenerates geometry.

Only paths, ellipses, and rectangles are used, with at most 26 primitives per
scene. Closed contours use 96 or 128 samples; open valley curves use 101 points.
The subsystem adds no dependencies, runtime entropy, network calls, animation,
filters, gradients, or noise layers.
222 changes: 222 additions & 0 deletions src/features/ideas/procedural-art/families/contours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {
cross,
line,
trace,
TAU,
type ArtVariant,
type Point,
type VariantGenerator,
} from "../primitives.js";
import type { ArtPrimitive } from "../scene.js";

// All rings in a terrain object share one deformation field. The sampler is
// geometry-only; each composition below owns its silhouette and contour levels.
function contour(
curve: (angle: number) => Point,
index: number,
samples: 96 | 128 = 128,
): ArtPrimitive {
const points = Array.from({ length: samples }, (_, i) =>
curve((i / samples) * TAU),
);
return {
...trace(
points,
index === 3 ? "primary" : index % 3 === 0 ? "border-strong" : "border",
true,
),
opacity: index === 3 ? 0.55 : 0.85,
};
}

const basin: VariantGenerator = ({ geometry: g, details: d }) => {
// Restore V1's silhouette and sampling exactly within this existing V2 slot.
// Both phases belong to one shared field; only the ring scale changes.
const x = g(530, 650),
y = g(125, 170),
rx = g(155, 205),
ry = g(80, 110),
phase = g(0, TAU),
phase2 = g(0, TAU);
const shapes: ArtPrimitive[] = [];
for (let i = 0; i < 8; i++) {
const scale = 0.25 + i * 0.145;
shapes.push(
contour(
(t) => {
const r =
1 +
0.13 * Math.sin(t * 3 + phase) +
0.07 * Math.cos(t * 5 + phase2);
return [
x + Math.cos(t) * rx * scale * r,
y + Math.sin(t) * ry * scale * r,
];
},
i,
96,
),
);
}
shapes.push(cross(Number(x.toFixed(3)), Number(y.toFixed(3)), 4));
const markX = Number((x + d(235, 280)).toFixed(3));
shapes.push(
line(markX, 76, markX, 148),
line(markX - 5, 76, markX + 5, 76),
line(markX - 5, 148, markX + 5, 148),
);
return shapes;
};

const dualPeak: VariantGenerator = ({ geometry: g, details: d }) => {
const x = g(565, 625),
y = g(110, 125),
span = g(255, 285),
height = g(98, 108);
const shapes: ArtPrimitive[] = [];
// Two summits sit inside a shared saddle-shaped perimeter, not two detached
// contour stickers. The inner nests fit inside the narrowest outer envelope.
for (let i = 0; i < 4; i++) {
const scale = 0.8 + i * 0.075;
shapes.push(
contour(
(t) => [
x + Math.cos(t) * span * scale,
y + Math.sin(t) * height * scale * (0.65 + 0.35 * Math.cos(t) ** 2),
],
i,
),
);
}
const count = Math.floor(d(3, 5));
for (const direction of [-1, 1]) {
const center = x + direction * span * 0.38;
for (let i = 0; i < count; i++) {
const radius = 15 + i * 11;
shapes.push(
contour(
(t) => [
center + Math.cos(t) * radius * 1.15,
y + Math.sin(t) * radius * 0.65,
],
i + 4,
),
);
}
}
return shapes;
};

const elongatedRidge: VariantGenerator = ({ geometry: g, details: d }) => {
const x = g(570, 620),
y = g(116, 130),
width = g(280, 320),
height = g(63, 80),
bend = g(22, 38);
const shapes: ArtPrimitive[] = [];
const count = Math.floor(d(7, 10));
for (let i = 0; i < count; i++) {
const scale = 0.18 + (0.82 * i) / (count - 1);
shapes.push(
contour((t) => {
const u = Math.cos(t) * scale;
return [
x + width * u,
y + height * Math.sin(t) * scale + bend * Math.sin(u * Math.PI),
];
}, i),
);
}
return shapes;
};

const valley: VariantGenerator = ({ geometry: g, details: d }) => {
const x = g(565, 625),
y = g(75, 90),
width = g(250, 290),
depth = g(42, 55),
bend = g(0.12, 0.25);
const shapes: ArtPrimitive[] = [];
const count = Math.floor(d(7, 10));
// Open contours follow the same valley floor and bank curvature. Their common
// domain and ordered vertical offsets keep one continuous terrain section.
for (let i = 0; i < count; i++) {
const points: Point[] = [];
for (let step = 0; step <= 100; step++) {
const u = step / 50 - 1;
points.push([
x + width * u,
y +
i * 13 +
depth * (Math.sqrt(u * u + 0.04) - 0.2) +
bend * depth * Math.sin(u * Math.PI),
]);
}
shapes.push({
...trace(
points,
i === 3 ? "primary" : i % 3 === 0 ? "border-strong" : "border",
),
opacity: i === 3 ? 0.55 : 0.85,
});
}
return shapes;
};

const asymmetricMass: VariantGenerator = ({ geometry: g, details: d }) => {
const x = g(555, 600),
y = g(110, 125),
width = g(230, 260),
height = g(86, 100),
phase = g(-0.35, 0.35);
const shapes: ArtPrimitive[] = [];
const count = Math.floor(d(7, 10));
// Scale about a common summit. The pear-shaped envelope shifts mass to one
// side while retaining nested, non-intersecting level sets.
for (let i = 0; i < count; i++) {
const scale = 0.18 + (0.82 * i) / (count - 1);
shapes.push(
contour((t) => {
const r = 1 + 0.27 * Math.cos(t) + 0.08 * Math.sin(3 * t + phase);
return [
x + Math.cos(t) * width * scale * r,
y + Math.sin(t) * height * scale * r,
];
}, i),
);
}
shapes.push(cross(x, y, 3));
return shapes;
};

const croppedMass: VariantGenerator = ({ geometry: g, details: d }) => {
const x = g(555, 620),
y = g(190, 210),
width = g(285, 330),
height = g(160, 180),
phase = g(0, TAU);
const shapes: ArtPrimitive[] = [];
const count = Math.floor(d(8, 11));
for (let i = 0; i < count; i++) {
const scale = 0.25 + (0.75 * i) / (count - 1);
shapes.push(
contour((t) => {
const r = 1 + 0.08 * Math.sin(3 * t + phase);
return [
x + Math.cos(t) * width * scale * r,
y + Math.sin(t) * height * scale * r,
];
}, i),
);
}
return shapes;
};

export const contours = Object.freeze([
{ name: "single-basin", generate: basin },
{ name: "dual-peak", generate: dualPeak },
{ name: "elongated-ridge", generate: elongatedRidge },
{ name: "valley", generate: valley },
{ name: "asymmetric-mass", generate: asymmetricMass },
{ name: "cropped-mass", generate: croppedMass },
] satisfies ArtVariant[]);
Loading
Loading