diff --git a/README.md b/README.md
index 678053b..daa7210 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,24 @@ bun run build
The static build lands in `dist/`.
+
+## Writing answers and survey direction
+
+Interviewer is becoming more than a renderer. The intended write model is:
+
+- **Closed interview:** one intended subject signs in and answers questions for a session.
+- **Open survey:** a session can accept answers from many signed-in respondents.
+
+The current static app does **not** ask for app passwords. That is deliberate. Browser writing should use ATProto OAuth (authorization code flow, PKCE, DPoP, and public client metadata). Until that is wired, unanswered questions show a safe answer-record payload and an XRPC `createRecord` template so protocol users can still answer without putting credentials into the page.
+
+Future work:
+
+- publish OAuth client metadata for Interviewer
+- add sign-in and session state
+- write `network.sensemaker.answer` records to the respondent's PDS
+- extend session semantics with `mode: closed | open` / `status: open | closed`
+- index open-survey answers across many repos
+
## Header cards
Generate a 1200×628 PNG card:
diff --git a/src/main.tsx b/src/main.tsx
index 391fbda..10e5c98 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -9,6 +9,8 @@ type SessionRecord = {
interviewer: string
topic?: string
introduction?: string
+ mode?: "closed" | "open"
+ status?: "open" | "closed"
createdAt: string
}
@@ -151,6 +153,45 @@ function atUriToBsky(uri: string): string | null {
}
}
+function createAnswerRecord(questionUri: string, text: string): AnswerRecord {
+ return {
+ $type: "network.sensemaker.answer",
+ question: questionUri,
+ text,
+ createdAt: new Date().toISOString(),
+ }
+}
+
+function AnswerComposer({ questionUri }: { questionUri: string }) {
+ const [text, setText] = useState("")
+ const answerRecord = createAnswerRecord(questionUri, text)
+ const record = JSON.stringify(answerRecord, null, 2)
+ const body = JSON.stringify({ repo: "YOUR_DID", collection: COLLECTIONS.answer, record: answerRecord }, null, 2)
+ const curl = `curl -X POST "https://YOUR_PDS/xrpc/com.atproto.repo.createRecord" \\
+ -H "Authorization: Bearer YOUR_ACCESS_JWT" \\
+ -H "Content-Type: application/json" \\
+ -d '${body.replace(/'/g, "'\\''")}'`
+
+ async function copy(value: string) {
+ await navigator.clipboard?.writeText(value)
+ }
+
+ return (
+
+ )
+}
+
function InterviewPage() {
const params = new URLSearchParams(window.location.search)
const [input, setInput] = useState(params.get("session") ?? DEFAULT_SESSION)
@@ -255,7 +296,7 @@ function InterviewPage() {
{sessions.map((session) => (
-
{session.value.topic ?? "Interview"}
+
{session.value.mode === "open" ? "Open survey" : (session.value.topic ?? "Interview")}
{session.value.title}
{session.value.introduction ?? "ATProto-native interview session."}
{session.uri}
@@ -276,10 +317,19 @@ function InterviewPage() {
Interviewer{shortDid(data.session.interviewer)}
Created{formatDate(data.session.createdAt)}
Status{data.answers.length}/{data.questions.length} answered
+
Mode{data.session.mode === "open" ? "Open survey" : "Closed interview"}
{data.session.introduction &&
{data.session.introduction}
}
+
+
+
Writing answers
+
Sign-in answering is the next protocol step.
+
ATProto OAuth is the right write path: authorization code flow, PKCE, DPoP, and a public client metadata URL. This static build does not ask for app passwords. Until OAuth is wired, unanswered questions expose a safe answer-record payload and XRPC template.
+
+
+
{data.questions.map((question, index) => {
const answer = answerByQuestion.get(question.uri)
@@ -305,7 +355,10 @@ function InterviewPage() {
{answer.uri}{answerLink && <> · view post>}
) : (
- No answer record yet.
+ <>
+ No answer record yet.
+
+ >
)}
diff --git a/src/styles.css b/src/styles.css
index 6df2397..51c35db 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -197,6 +197,65 @@ dd {
font-size: 20px;
line-height: 1.5;
}
+.write-card {
+ margin: 0 0 36px;
+ padding: 24px;
+ background: rgba(255,255,255,0.025);
+ border: 1px solid var(--line);
+ border-radius: 22px;
+}
+.write-card h2 {
+ margin: 0;
+ font-family: Newsreader, Georgia, serif;
+ font-size: clamp(28px, 3vw, 40px);
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+}
+.write-card p:not(.label) {
+ max-width: 760px;
+ color: var(--muted);
+ font-size: 17px;
+ line-height: 1.45;
+}
+.composer {
+ margin-top: 20px;
+ padding: 18px;
+ background: rgba(255,255,255,0.025);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+}
+textarea {
+ width: 100%;
+ min-height: 150px;
+ resize: vertical;
+ padding: 14px;
+ color: var(--fg);
+ background: #070707;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ font: 17px/1.45 "Source Serif 4", Georgia, serif;
+}
+.composer-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 12px;
+}
+details {
+ margin-top: 14px;
+ color: var(--muted);
+}
+summary { cursor: pointer; }
+pre {
+ overflow: auto;
+ padding: 14px;
+ background: #050505;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ color: var(--accent);
+ font-size: 13px;
+ line-height: 1.4;
+}
.qa-list {
border-top: 1px solid var(--line);
}
@@ -270,6 +329,7 @@ dd {
.qa { grid-template-columns: 1fr; gap: 14px; padding: 34px 0; }
.number { font-size: 30px; }
.answer-block { padding: 20px; }
+ .composer-actions { flex-direction: column; }
}
@media (min-width: 1100px) {