From d4436483a9d2a5e22af19ebdfa895cb1a51a81fe Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sat, 6 Jun 2026 22:55:24 +0700 Subject: [PATCH] update skills --- .claude/skills/write-as-oleg/SKILL.md | 4 +- .claude/skills/write-as-oleg/lint-voice.mjs | 265 ++++++++++++++++++ .claude/skills/write-as-oleg/lint-voice.py | 254 ----------------- CLAUDE.md | 3 +- .../Porchlight Redesign (standalone).html | 181 ++++++++++++ 5 files changed, 450 insertions(+), 257 deletions(-) create mode 100644 .claude/skills/write-as-oleg/lint-voice.mjs delete mode 100644 .claude/skills/write-as-oleg/lint-voice.py create mode 100644 portfolio/temp/Porchlight Redesign (standalone).html diff --git a/.claude/skills/write-as-oleg/SKILL.md b/.claude/skills/write-as-oleg/SKILL.md index 193ae11..0a10f7c 100644 --- a/.claude/skills/write-as-oleg/SKILL.md +++ b/.claude/skills/write-as-oleg/SKILL.md @@ -28,9 +28,9 @@ Produces English text artifacts under Oleg's name: cover letters, application es Every generated text **must** pass through the voice linter before it reaches Oleg. ```bash -python3 .claude/skills/write-as-oleg/lint-voice.py "full text here" +node .claude/skills/write-as-oleg/lint-voice.mjs "full text here" # or via stdin: -echo "full text" | python3 .claude/skills/write-as-oleg/lint-voice.py +echo "full text" | node .claude/skills/write-as-oleg/lint-voice.mjs ``` **Loop protocol:** diff --git a/.claude/skills/write-as-oleg/lint-voice.mjs b/.claude/skills/write-as-oleg/lint-voice.mjs new file mode 100644 index 0000000..ba280e2 --- /dev/null +++ b/.claude/skills/write-as-oleg/lint-voice.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/** + * lint-voice.mjs — deterministic voice linter for Oleg's texts. + * + * Checks for AI/corporate artifacts, wrong typography, and banned patterns + * that make text sound like a model rather than Oleg. + * + * Usage: + * node lint-voice.mjs "text to check" + * echo "text" | node lint-voice.mjs + * node lint-voice.mjs < input.txt + * + * Exit codes: + * 0 — clean (no ERRORs; WARNs alone do not fail) + * 1 — ERRORs found (agent must fix and re-run) + * 2 — usage error + */ + +// [level, id, pattern, message] +// level: "ERROR" = must fix before delivery; "WARN" = check in context, may be OK +const CHECKS = [ + // ── Typography ───────────────────────────────────────────────────────────── + ["ERROR", "EM_DASH", + /—/g, + "Em-dash (—). Replace with colon, comma, or rephrase as separate sentence."], + + ["ERROR", "SMART_QUOTES", + /[""„«»]/g, + 'Smart/typographic double quote. Use straight " or rephrase.'], + + ["WARN", "SMART_APOSTROPHE", + /[''`]/g, + "Smart/curly apostrophe or backtick. Use straight apostrophe '."], + + // ── AI / corporate words ─────────────────────────────────────────────────── + ["ERROR", "AI_LEVERAGE", + /\bleverag(?:e|es|ed|ing)\b/gi, + '"leverage" — AI/corporate word. Replace with plain verb (use, apply, build on).'], + + ["ERROR", "AI_ROBUST", + /\brobust\b/gi, + '"robust" — AI/corporate word. Replace with specific description.'], + + ["ERROR", "AI_SEAMLESS", + /\bseamless(?:ly)?\b/gi, + '"seamless" — AI/corporate word. Cut or replace with plain description.'], + + ["ERROR", "AI_PASSIONATE", + /\bpassionat(?:e|ely)\b|\bmy passion\b/gi, + '"passionate/passion" — AI/corporate word. Replace with direct statement.'], + + ["ERROR", "AI_THRILLED", + /\bthrilled\b/gi, + '"thrilled" — AI word. Replace with direct statement.'], + + ["ERROR", "AI_DELVE", + /\bdelv(?:e|ing|ed)\b/gi, + '"delve" — AI word. Replace with explore / look into / dig.'], + + ["ERROR", "AI_TAPESTRY", + /\btapestry\b/gi, + '"tapestry" — AI word. Remove or replace.'], + + ["ERROR", "AI_EMPOWER", + /\bempower(?:s|ed|ing)?\b/gi, + '"empower" — AI/corporate word. Replace with plain verb.'], + + ["ERROR", "AI_STREAMLINE", + /\bstreamline[ds]?\b|\bstreamlining\b/gi, + '"streamline" — AI/corporate word. Replace with plain verb.'], + + ["ERROR", "AI_ACTIONABLE", + /\bactionable\b/gi, + '"actionable" — AI/corporate word. Replace with plain description.'], + + ["ERROR", "AI_UNLOCK", + /\bunlock(?:s|ed|ing)?\b/gi, + '"unlock" — AI/corporate word. Replace with plain verb.'], + + ["ERROR", "AI_CUTTING_EDGE", + /cutting[- ]edge/gi, + '"cutting-edge" — AI/corporate phrase. Replace with specific description.'], + + ["WARN", "AI_INNOVATIVE", + /\binnovativ(?:e|ely)\b|\binnovation\b/gi, + '"innovative/innovation" — often AI/corporate filler. Replace with specific if used generically.'], + + // ── Sentence openers (capitalised opener form) ──────────────────────────── + // Matches word at line start OR after terminal punctuation + whitespace + ["ERROR", "OPENER_FURTHERMORE", + /(?:^|(?<=[.!?])[ \t]+)Furthermore[,\s]/gm, + '"Furthermore" opener. Cut — connection should come from content, not connective.'], + + ["ERROR", "OPENER_MOREOVER", + /(?:^|(?<=[.!?])[ \t]+)Moreover[,\s]/gm, + '"Moreover" opener. Cut — restructure.'], + + ["ERROR", "OPENER_ADDITIONALLY", + /(?:^|(?<=[.!?])[ \t]+)Additionally[,\s]/gm, + '"Additionally" opener. Cut — restructure without connective.'], + + ["ERROR", "OPENER_ULTIMATELY", + /(?:^|(?<=[.!?])[ \t]+)Ultimately[,\s]/gm, + '"Ultimately" opener. Cut — restructure.'], + + ["ERROR", "OPENER_HOWEVER", + /(?:^|(?<=[.!?])[ \t]+)However[,\s]/gm, + '"However" opener. Cut — use contrast from content, not a gluing connective.'], + + ["ERROR", "OPENER_IMPORTANTLY", + /(?:^|(?<=[.!?])[ \t]+)Importantly[,\s]/gm, + '"Importantly" opener — filler frame. Cut.'], + + ["ERROR", "OPENER_THAT_SAID", + /(?:^|(?<=[.!?])[ \t]+)That said[,\s]/gm, + '"That said" opener. Cut — restructure.'], + + ["ERROR", "OPENER_IN_CONCLUSION", + /in conclusion[,\s]/gi, + '"In conclusion" — empty closing summary. Cut entirely.'], + + ["ERROR", "OPENER_OVERALL", + /(?:^|(?<=[.!?])[ \t]+)Overall[,\s]/gm, + '"Overall" opener — empty summary signal. Cut.'], + + ["WARN", "OPENER_SO_COMMA", + /(?:^|(?<=[.!?])[ \t]+)So[,\s]/gm, + '"So," opener. Oleg cuts these — connection should come from content.'], + + // ── Filler frames ────────────────────────────────────────────────────────── + ["ERROR", "FILLER_WORTH_NOTING", + /it'?s worth noting/gi, + '"It\'s worth noting" — filler frame. Cut, say the thing directly.'], + + ["ERROR", "FILLER_IMPORTANT_REMEMBER", + /it'?s important to remember/gi, + '"It\'s important to remember" — filler frame. Cut.'], + + ["ERROR", "FILLER_I_AM_WRITING", + /\bI am writing to\b/gi, + '"I am writing to" — generic opener. Replace with direct lead.'], + + ["ERROR", "FILLER_IN_TODAYS", + /in today'?s\b/gi, + '"In today\'s..." — AI/generic opener. Cut.'], + + // ── AI structural patterns ───────────────────────────────────────────────── + ["ERROR", "PATTERN_ITS_NOT_JUST", + /it'?s not just/gi, + '"it\'s not just X, it\'s Y" construction. Rephrase as direct statement.'], + + ["WARN", "PATTERN_NOT_X_BUT", + /\bnot \w+(?:\s+\w+){0,3},?\s+but\b/gi, + '"not X, but Y" — often AI phrasing. Rephrase if rhetorical.'], + + ["ERROR", "PATTERN_HONEST_FRAMING", + /honest framing|one thing to flag|I want to be honest about/gi, + '"honest framing / one thing to flag" template. Not Oleg\'s pattern — remove.'], + + // ── AI closings ──────────────────────────────────────────────────────────── + ["ERROR", "CLOSING_LOOKING_FORWARD", + /looking forward to/gi, + '"Looking forward to" closing. Replace e.g. "Open to chat if it\'s a fit."'], + + ["ERROR", "CLOSING_HAPPY_TO", + /\bhappy to\b/gi, + '"happy to" — AI phrasing. Replace with plain statement.'], + + // ── Warn-only borderline cases ───────────────────────────────────────────── + ["WARN", "WARN_NAVIGATE", + /\bnavigat(?:e|ing|ed)\b/gi, + '"navigate" — check if metaphorical AI-speak ("navigate challenges"). OK if literal.'], + + ["WARN", "WARN_LANDSCAPE", + /\blandscape\b/gi, + '"landscape" — check if metaphorical AI-speak. OK if literal/technical.'], +]; + +function lineOf(text, pos) { + return text.slice(0, pos).split("\n").length; +} + +function getContext(text, start, end, window = 60) { + const from = Math.max(0, start - window); + const to = Math.min(text.length, end + window); + let snippet = text.slice(from, to).replace(/\s+/g, " ").trim(); + if (from > 0) snippet = "…" + snippet; + if (to < text.length) snippet = snippet + "…"; + return snippet; +} + +function runChecks(text) { + const findings = []; + for (const [level, id, pattern, message] of CHECKS) { + // Reset lastIndex before each use (patterns are defined with /g) + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(text)) !== null) { + findings.push({ + level, + id, + line: lineOf(text, m.index), + context: getContext(text, m.index, m.index + m[0].length), + message, + }); + // Prevent infinite loops on zero-width matches + if (m[0].length === 0) pattern.lastIndex++; + } + } + findings.sort((a, b) => a.line - b.line || a.id.localeCompare(b.id)); + return findings; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +async function main() { + let text; + + if (process.argv.length > 2) { + text = process.argv.slice(2).join(" "); + } else if (!process.stdin.isTTY) { + text = await readStdin(); + } else { + process.stderr.write('Usage: node lint-voice.mjs "text" OR pipe text via stdin\n'); + process.exit(2); + } + + if (!text.trim()) { + process.stdout.write("✓ CLEAN — empty input.\n"); + process.exit(0); + } + + const findings = runChecks(text); + const errors = findings.filter(f => f.level === "ERROR"); + const warns = findings.filter(f => f.level === "WARN"); + + if (findings.length === 0) { + process.stdout.write("✓ CLEAN — no issues found.\n"); + process.exit(0); + } + + process.stdout.write(`Found ${findings.length} issue(s): ${errors.length} ERROR, ${warns.length} WARN\n\n`); + + for (const f of findings) { + const action = f.level === "ERROR" ? "Fix" : "Note"; + process.stdout.write(`[${f.level}] ${f.id} — line ${f.line}\n`); + process.stdout.write(` Context: "${f.context}"\n`); + process.stdout.write(` ${action}: ${f.message}\n\n`); + } + + process.stdout.write("---\n"); + if (errors.length > 0) { + process.stdout.write(`EXIT 1 — ${errors.length} error(s) present. Fix and re-run.\n`); + process.exit(1); + } else { + process.stdout.write("EXIT 0 — no errors (warnings only). Review WARNs if applicable.\n"); + process.exit(0); + } +} + +main(); diff --git a/.claude/skills/write-as-oleg/lint-voice.py b/.claude/skills/write-as-oleg/lint-voice.py deleted file mode 100644 index 0ae8e5a..0000000 --- a/.claude/skills/write-as-oleg/lint-voice.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -""" -lint-voice.py — deterministic voice linter for Oleg's texts. - -Checks for AI/corporate artifacts, wrong typography, and banned patterns -that make text sound like a model rather than Oleg. - -Usage: - python lint-voice.py "text to check" - echo "text" | python lint-voice.py - python lint-voice.py < input.txt - -Exit codes: - 0 — clean (no ERRORs; WARNs alone do not fail) - 1 — ERRORs found (agent must fix and re-run) - 2 — usage error -""" - -import re -import sys - - -# (level, id, pattern, message) -# level: "ERROR" = must fix before delivery; "WARN" = check in context, may be OK -CHECKS = [ - # ── Typography ──────────────────────────────────────────────────────────── - ("ERROR", "EM_DASH", - r"—", - "Em-dash (—). Replace with colon, comma, or rephrase as separate sentence."), - - ("ERROR", "SMART_QUOTES", - r'[""„«»]', - 'Smart/typographic double quote. Use straight " or rephrase.'), - - ("WARN", "SMART_APOSTROPHE", - r"[''`]", - "Smart/curly apostrophe or backtick. Use straight apostrophe '."), - - # ── AI / corporate words ────────────────────────────────────────────────── - ("ERROR", "AI_LEVERAGE", - r"(?i)\bleverag(e|es|ed|ing)\b", - '"leverage" — AI/corporate word. Replace with plain verb (use, apply, build on).'), - - ("ERROR", "AI_ROBUST", - r"(?i)\brobust\b", - '"robust" — AI/corporate word. Replace with specific description.'), - - ("ERROR", "AI_SEAMLESS", - r"(?i)\bseamless(ly)?\b", - '"seamless" — AI/corporate word. Cut or replace with plain description.'), - - ("ERROR", "AI_PASSIONATE", - r"(?i)\bpassionat(e|ely)\b|\bmy passion\b", - '"passionate/passion" — AI/corporate word. Replace with direct statement.'), - - ("ERROR", "AI_THRILLED", - r"(?i)\bthrilled\b", - '"thrilled" — AI word. Replace with direct statement.'), - - ("ERROR", "AI_DELVE", - r"(?i)\bdelv(e|ing|ed)\b", - '"delve" — AI word. Replace with explore / look into / dig.'), - - ("ERROR", "AI_TAPESTRY", - r"(?i)\btapestry\b", - '"tapestry" — AI word. Remove or replace.'), - - ("ERROR", "AI_EMPOWER", - r"(?i)\bempower(s|ed|ing)?\b", - '"empower" — AI/corporate word. Replace with plain verb.'), - - ("ERROR", "AI_STREAMLINE", - r"(?i)\bstreamline[ds]?\b|\bstreamlining\b", - '"streamline" — AI/corporate word. Replace with plain verb.'), - - ("ERROR", "AI_ACTIONABLE", - r"(?i)\bactionable\b", - '"actionable" — AI/corporate word. Replace with plain description.'), - - ("ERROR", "AI_UNLOCK", - r"(?i)\bunlock(s|ed|ing)?\b", - '"unlock" — AI/corporate word. Replace with plain verb.'), - - ("ERROR", "AI_CUTTING_EDGE", - r"(?i)cutting[- ]edge", - '"cutting-edge" — AI/corporate phrase. Replace with specific description.'), - - ("WARN", "AI_INNOVATIVE", - r"(?i)\binnovativ(e|ely)\b|\binnovation\b", - '"innovative/innovation" — often AI/corporate filler. Replace with specific if used generically.'), - - # ── Sentence openers (capitalised opener form) ──────────────────────────── - # Pattern: word at line start OR after terminal punctuation + whitespace - ("ERROR", "OPENER_FURTHERMORE", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Furthermore[,\s]", - '"Furthermore" opener. Cut — connection should come from content, not connective.'), - - ("ERROR", "OPENER_MOREOVER", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Moreover[,\s]", - '"Moreover" opener. Cut — restructure.'), - - ("ERROR", "OPENER_ADDITIONALLY", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Additionally[,\s]", - '"Additionally" opener. Cut — restructure without connective.'), - - ("ERROR", "OPENER_ULTIMATELY", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Ultimately[,\s]", - '"Ultimately" opener. Cut — restructure.'), - - ("ERROR", "OPENER_HOWEVER", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)However[,\s]", - '"However" opener. Cut — use contrast from content, not a gluing connective.'), - - ("ERROR", "OPENER_IMPORTANTLY", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Importantly[,\s]", - '"Importantly" opener — filler frame. Cut.'), - - ("ERROR", "OPENER_THAT_SAID", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)That said[,\s]", - '"That said" opener. Cut — restructure.'), - - ("ERROR", "OPENER_IN_CONCLUSION", - r"(?i)in conclusion[,\s]", - '"In conclusion" — empty closing summary. Cut entirely.'), - - ("ERROR", "OPENER_OVERALL", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)Overall[,\s]", - '"Overall" opener — empty summary signal. Cut.'), - - ("WARN", "OPENER_SO_COMMA", - r"(?m)(?:^[ \t]*|(?<=[.!?])[ \t]+)So[,\s]", - '"So," opener. Oleg cuts these — connection should come from content.'), - - # ── Filler frames ───────────────────────────────────────────────────────── - ("ERROR", "FILLER_WORTH_NOTING", - r"(?i)it'?s worth noting", - '"It\'s worth noting" — filler frame. Cut, say the thing directly.'), - - ("ERROR", "FILLER_IMPORTANT_REMEMBER", - r"(?i)it'?s important to remember", - '"It\'s important to remember" — filler frame. Cut.'), - - ("ERROR", "FILLER_I_AM_WRITING", - r"(?i)\bI am writing to\b", - '"I am writing to" — generic opener. Replace with direct lead.'), - - ("ERROR", "FILLER_IN_TODAYS", - r"(?i)in today'?s\b", - '"In today\'s..." — AI/generic opener. Cut.'), - - # ── AI structural patterns ──────────────────────────────────────────────── - ("ERROR", "PATTERN_ITS_NOT_JUST", - r"(?i)it'?s not just", - '"it\'s not just X, it\'s Y" construction. Rephrase as direct statement.'), - - ("WARN", "PATTERN_NOT_X_BUT", - r"(?i)\bnot [\w]+(?:\s+[\w]+){0,3},?\s+but\b", - '"not X, but Y" — often AI phrasing. Rephrase if rhetorical.'), - - ("ERROR", "PATTERN_HONEST_FRAMING", - r"(?i)honest framing|one thing to flag|I want to be honest about", - '"honest framing / one thing to flag" template. Not Oleg\'s pattern — remove.'), - - # ── AI closings ─────────────────────────────────────────────────────────── - ("ERROR", "CLOSING_LOOKING_FORWARD", - r"(?i)looking forward to", - '"Looking forward to" closing. Replace e.g. "Open to chat if it\'s a fit."'), - - ("ERROR", "CLOSING_HAPPY_TO", - r"(?i)\bhappy to\b", - '"happy to" — AI phrasing. Replace with plain statement.'), - - # ── Warn-only borderline cases ──────────────────────────────────────────── - ("WARN", "WARN_NAVIGATE", - r"(?i)\bnavigat(e|ing|ed)\b", - '"navigate" — check if metaphorical AI-speak ("navigate challenges"). OK if literal.'), - - ("WARN", "WARN_LANDSCAPE", - r"(?i)\blandscape\b", - '"landscape" — check if metaphorical AI-speak. OK if literal/technical.'), -] - - -def get_context(text: str, match: re.Match, window: int = 60) -> str: - start = max(0, match.start() - window) - end = min(len(text), match.end() + window) - snippet = text[start:end] - snippet = " ".join(snippet.split()) - prefix = "…" if start > 0 else "" - suffix = "…" if end < len(text) else "" - return f"{prefix}{snippet}{suffix}" - - -def line_of(text: str, pos: int) -> int: - return text[:pos].count("\n") + 1 - - -def run_checks(text: str) -> list[dict]: - findings = [] - for level, check_id, pattern, message in CHECKS: - for m in re.finditer(pattern, text): - findings.append({ - "level": level, - "id": check_id, - "line": line_of(text, m.start()), - "context": get_context(text, m), - "message": message, - }) - findings.sort(key=lambda f: (f["line"], f["id"])) - return findings - - -def main() -> None: - if len(sys.argv) > 1: - text = " ".join(sys.argv[1:]) - elif not sys.stdin.isatty(): - text = sys.stdin.read() - else: - print("Usage: python lint-voice.py \"text\" OR pipe text via stdin", file=sys.stderr) - sys.exit(2) - - if not text.strip(): - print("✓ CLEAN — empty input.", file=sys.stderr) - sys.exit(0) - - findings = run_checks(text) - errors = [f for f in findings if f["level"] == "ERROR"] - warns = [f for f in findings if f["level"] == "WARN"] - - if not findings: - print("✓ CLEAN — no issues found.") - sys.exit(0) - - print(f"Found {len(findings)} issue(s): {len(errors)} ERROR, {len(warns)} WARN\n") - - for f in findings: - label = f"[{f['level']}]" - action = "Fix" if f["level"] == "ERROR" else "Note" - print(f"{label} {f['id']} — line {f['line']}") - print(f' Context: "{f["context"]}"') - print(f" {action}: {f['message']}") - print() - - print("---") - if errors: - print(f"EXIT 1 — {len(errors)} error(s) present. Fix and re-run.") - sys.exit(1) - else: - print("EXIT 0 — no errors (warnings only). Review WARNs if applicable.") - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/CLAUDE.md b/CLAUDE.md index 37078a3..8bf5053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ The visual format of every generated PDF must match `base/reference/Oleg_Proskur ## Writing texts as Oleg -Any English text that goes out under Oleg's name — cover letter, application essay, form answer, outreach DM, CV bullet — must be written with the **`write-as-oleg` skill** (`.claude/skills/write-as-oleg/`). The skill consolidates his voice profile, writing process, and a paired example dictionary, and runs a mandatory deterministic linter (`lint-voice.py`) before delivery. Do not write such texts without loading this skill first. +Any English text that goes out under Oleg's name — cover letter, application essay, form answer, outreach DM, CV bullet — must be written with the **`write-as-oleg` skill** (`.claude/skills/write-as-oleg/`). The skill consolidates his voice profile, writing process, and a paired example dictionary, and runs a mandatory deterministic linter (`lint-voice.mjs`) before delivery. Do not write such texts without loading this skill first. --- @@ -282,6 +282,7 @@ The CSS in `templates/cv-style.css` already encodes these — when generating HT - **Language**: every artifact (HTML, MD, PDF, tracking rows, card notes) is in English. Always. - **Filenames**: snake_case for base CVs (`oleg_proskurin__cv.md`), kebab-case for tailored slugs. Tailored CV: `cv.md` inside `tailored//`. - **Dates**: absolute (`2026-05-24`), never "last Thursday". +- **Scripts**: all scripts in this project must be written in **Node.js** or **bash** — no Python. Use Node.js for anything with logic (parsing, transformation, linting, generation); use bash for simple shell glue (piping, file ops, invoking other tools). - **Don't edit the reference PDF.** It is the immutable visual anchor. - **Don't auto-regenerate PDFs** unless Oleg asks — show diffs first when content changes. - **Verify before claiming "done"**: after `pnpm pdf`, confirm the PDF exists and looks correct (open it, check page count). If you can't visually verify, say so. diff --git a/portfolio/temp/Porchlight Redesign (standalone).html b/portfolio/temp/Porchlight Redesign (standalone).html new file mode 100644 index 0000000..b28308f --- /dev/null +++ b/portfolio/temp/Porchlight Redesign (standalone).html @@ -0,0 +1,181 @@ + + + + + Porchlight — Oleg Proskurin · Case Study (Editorial) + + + + +
+ + + PORCH + + CASE STUDY — OLEG PROSKURIN + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file