diff --git a/scripts/generate-checklist-pdf.py b/scripts/generate-checklist-pdf.py
new file mode 100644
index 000000000..db71f8ef7
--- /dev/null
+++ b/scripts/generate-checklist-pdf.py
@@ -0,0 +1,136 @@
+"""
+Generate the RecordSponge Expungement Checklist PDF.
+
+Requirements: pip install reportlab
+
+Output: src/frontend/public/docs/expungement-checklist.pdf
+
+Run from the project root:
+ python scripts/generate-checklist-pdf.py
+"""
+
+import os
+from reportlab.lib.pagesizes import letter
+from reportlab.lib.units import inch
+from reportlab.lib.colors import HexColor, black, white
+from reportlab.platypus import (
+ SimpleDocTemplate, Paragraph, Spacer, HRFlowable, Flowable
+)
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+OUTPUT_PATH = os.path.join(SCRIPT_DIR, "..", "src", "frontend", "public", "docs", "expungement-checklist.pdf")
+
+
+class CheckboxStep(Flowable):
+ """A checkbox followed by step text, vertically aligned."""
+
+ def __init__(self, text, style, checkbox_size=11):
+ super().__init__()
+ self.text = text
+ self.style = style
+ self.checkbox_size = checkbox_size
+ self._para = Paragraph(text, style)
+
+ def wrap(self, availWidth, availHeight):
+ text_width = availWidth - self.checkbox_size - 10
+ self._para_w, self._para_h = self._para.wrap(text_width, availHeight)
+ self.width = availWidth
+ self.height = self._para_h + 4
+ return self.width, self.height
+
+ def draw(self):
+ para_y = self.height - self._para_h
+ self._para.drawOn(self.canv, self.checkbox_size + 10, para_y)
+
+ cb_y = self.height - self._para_h + (self._para_h - self.checkbox_size) / 2 - 1
+
+ self.canv.setStrokeColor(black)
+ self.canv.setFillColor(white)
+ self.canv.setLineWidth(0.8)
+ self.canv.rect(0, max(0, cb_y), self.checkbox_size, self.checkbox_size, fill=1, stroke=1)
+
+
+def generate():
+ os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
+
+ doc = SimpleDocTemplate(
+ OUTPUT_PATH,
+ pagesize=letter,
+ topMargin=0.75 * inch,
+ bottomMargin=0.75 * inch,
+ leftMargin=0.75 * inch,
+ rightMargin=0.75 * inch,
+ )
+
+ styles = getSampleStyleSheet()
+ blue = HexColor("#357edd")
+ dark = HexColor("#333333")
+ gray = HexColor("#555555")
+
+ title_style = ParagraphStyle("ChecklistTitle", parent=styles["Title"], fontSize=20, textColor=dark, spaceAfter=6)
+ subtitle_style = ParagraphStyle("Subtitle", parent=styles["Normal"], fontSize=11, textColor=gray, spaceAfter=4)
+ step_style = ParagraphStyle("StepStyle", parent=styles["Normal"], fontSize=12, textColor=dark, fontName="Helvetica-Bold", spaceBefore=0, spaceAfter=4, leading=14)
+ sub_style = ParagraphStyle("SubStyle", parent=styles["Normal"], fontSize=11, textColor=gray, leftIndent=28, spaceBefore=2, spaceAfter=2)
+ note_style = ParagraphStyle("NoteStyle", parent=styles["Normal"], fontSize=11, textColor=gray, spaceBefore=0, spaceAfter=4, borderWidth=1, borderColor=HexColor("#cccccc"), borderPadding=8)
+
+ link_str = 'color="#357edd"'
+ story = []
+
+ story.append(Paragraph("RecordSponge Expungement Checklist", title_style))
+ story.append(Paragraph("A step-by-step guide to the expungement process", subtitle_style))
+ story.append(HRFlowable(width="100%", thickness=1, color=blue, spaceAfter=12))
+
+ steps = [
+ {
+ "title": "Log in to OECI",
+ "subs": [
+ "You will need an OECI account to search for criminal records.",
+ f'Purchase a subscription at courts.oregon.gov .',
+ ],
+ },
+ {
+ "title": "Search records",
+ "subs": [
+ "Ensure that Assumptions are met",
+ "Search by name and date of birth",
+ ],
+ },
+ {
+ "title": "Complete paperwork for expungement",
+ "subs": [
+ "This includes paperwork to modify financial obligations if applicable",
+ ],
+ },
+ {
+ "title": "Obtain fingerprints",
+ "subs": [
+ "Mail to Oregon State Police",
+ ],
+ },
+ {
+ "title": "File paperwork in appropriate courts",
+ "subs": [],
+ },
+ ]
+
+ for i, step in enumerate(steps, 1):
+ story.append(Spacer(1, 10))
+ story.append(CheckboxStep(f'Step {i}: {step["title"]}', step_style))
+ for sub in step["subs"]:
+ story.append(Paragraph(f"\u2022 {sub}", sub_style))
+
+ story.append(Spacer(1, 24))
+ story.append(Paragraph(
+ f'Note: If new to RecordSponge, confirm results with Michael Zhang at '
+ f'michael@qiu-qiulaw.com . '
+ f'For further details, visit recordsponge.com/manual .',
+ note_style,
+ ))
+
+ doc.build(story)
+ print(f"PDF generated: {os.path.abspath(OUTPUT_PATH)}")
+
+
+if __name__ == "__main__":
+ generate()
diff --git a/src/frontend/public/docs/expungement-checklist.pdf b/src/frontend/public/docs/expungement-checklist.pdf
new file mode 100644
index 000000000..fd1695435
Binary files /dev/null and b/src/frontend/public/docs/expungement-checklist.pdf differ
diff --git a/src/frontend/public/img/aliases.webp b/src/frontend/public/img/aliases.webp
new file mode 100644
index 000000000..795b4be17
Binary files /dev/null and b/src/frontend/public/img/aliases.webp differ
diff --git a/src/frontend/public/img/expanded-view-generate-paperwork.webp b/src/frontend/public/img/expanded-view-generate-paperwork.webp
new file mode 100644
index 000000000..2d4e60196
Binary files /dev/null and b/src/frontend/public/img/expanded-view-generate-paperwork.webp differ
diff --git a/src/frontend/public/img/expanded-view.webp b/src/frontend/public/img/expanded-view.webp
new file mode 100644
index 000000000..8224e5fb1
Binary files /dev/null and b/src/frontend/public/img/expanded-view.webp differ
diff --git a/src/frontend/public/img/full-results.webp b/src/frontend/public/img/full-results.webp
new file mode 100644
index 000000000..00e686d97
Binary files /dev/null and b/src/frontend/public/img/full-results.webp differ
diff --git a/src/frontend/public/img/generate-expungement-forms.webp b/src/frontend/public/img/generate-expungement-forms.webp
new file mode 100644
index 000000000..b4ea3b286
Binary files /dev/null and b/src/frontend/public/img/generate-expungement-forms.webp differ
diff --git a/src/frontend/public/img/search-form.webp b/src/frontend/public/img/search-form.webp
new file mode 100644
index 000000000..45ad3e6fb
Binary files /dev/null and b/src/frontend/public/img/search-form.webp differ
diff --git a/src/frontend/public/img/search-results.webp b/src/frontend/public/img/search-results.webp
new file mode 100644
index 000000000..79ac7dd16
Binary files /dev/null and b/src/frontend/public/img/search-results.webp differ
diff --git a/src/frontend/public/img/simple-search.webp b/src/frontend/public/img/simple-search.webp
new file mode 100644
index 000000000..0f6abf2e1
Binary files /dev/null and b/src/frontend/public/img/simple-search.webp differ
diff --git a/src/frontend/public/img/smart-search.webp b/src/frontend/public/img/smart-search.webp
new file mode 100644
index 000000000..483cab38c
Binary files /dev/null and b/src/frontend/public/img/smart-search.webp differ
diff --git a/src/frontend/public/img/wildcard-search.webp b/src/frontend/public/img/wildcard-search.webp
new file mode 100644
index 000000000..08a2cd1b1
Binary files /dev/null and b/src/frontend/public/img/wildcard-search.webp differ
diff --git a/src/frontend/src/components/App/App.test.tsx b/src/frontend/src/components/App/App.test.tsx
index b67989ffe..ec8086dd5 100644
--- a/src/frontend/src/components/App/App.test.tsx
+++ b/src/frontend/src/components/App/App.test.tsx
@@ -39,7 +39,7 @@ describe("App routing", () => {
["/", "winner"],
["/oeci", "ecourt"],
["/demo-record-search", "app demo"],
- ["/manual", "introduction"],
+ ["/manual", "RecordSponge"],
["/rules", "type eligibility rules"],
["/faq", "myth"],
["/appendix", "forms to file"],
diff --git a/src/frontend/src/components/Appendix/index.tsx b/src/frontend/src/components/Appendix/index.tsx
index 227d11420..7474fa66b 100644
--- a/src/frontend/src/components/Appendix/index.tsx
+++ b/src/frontend/src/components/Appendix/index.tsx
@@ -22,7 +22,7 @@ class Landing extends React.Component {
counties listed here, and will use the Stock Form for those
not listed. You can also fill out the forms manually if
preferred.{" "}
-
+
Learn more in the Manual
.
diff --git a/src/frontend/src/components/FillForms/index.tsx b/src/frontend/src/components/FillForms/index.tsx
index 2f43b14c5..0650221dd 100644
--- a/src/frontend/src/components/FillForms/index.tsx
+++ b/src/frontend/src/components/FillForms/index.tsx
@@ -136,7 +136,7 @@ export default function FillFormsIndex() {
available, it will be provided in the form. If it is not present
in OECI, some of the information may or may not be required in the
application; please consult the{" "}
-
+
Manual
.
@@ -162,7 +162,7 @@ export default function FillFormsIndex() {
Please read the complete instructions in the{" "}
-
+
Manual
{" "}
for filing the required forms for expungement. After downloading
diff --git a/src/frontend/src/components/Footer/index.tsx b/src/frontend/src/components/Footer/index.tsx
index 73d37b124..1ebc81bf7 100644
--- a/src/frontend/src/components/Footer/index.tsx
+++ b/src/frontend/src/components/Footer/index.tsx
@@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
export default class Footer extends React.Component {
public render() {
return (
-
+
diff --git a/src/frontend/src/components/Manual/EditingGuide.tsx b/src/frontend/src/components/Manual/EditingGuide.tsx
index 36a9eecaa..d9b0e3896 100644
--- a/src/frontend/src/components/Manual/EditingGuide.tsx
+++ b/src/frontend/src/components/Manual/EditingGuide.tsx
@@ -1,49 +1,31 @@
import React from "react";
import { HashLink as Link } from "react-router-hash-link";
-import useDisclosure from "../../hooks/useDisclosure";
import AddButton from "../RecordSearch/Record/AddButton";
import EditButton from "../RecordSearch/Record/EditButton";
import EditedBadge from "../RecordSearch/Record/EditedBadge";
import { createNextBlankCharge } from "../RecordSearch/Record/Case";
import EditChargePanel from "../RecordSearch/Record/EditChargePanel";
-import DisclosureIcon from "../common/DisclosureIcon";
+import Accordion from "../common/Accordion";
export default function EditingGuide() {
- const {
- disclosureIsExpanded,
- disclosureButtonProps,
- disclosureContentProps,
- } = useDisclosure();
-
return (
-
-
Editing Results
-
- RecordSponge allows in-line editing of search results to correct any
- errors or missing information. This is an advanced feature.
-
-
-
- Editing Guide
-
-
-
-
-
Why Edit
-
+
+
+
+ RecordSponge allows in-line editing of search results to correct any
+ errors or missing information. This is an advanced feature.
+
+
Why Edit
+
Sometimes the result of a Search doesn’t completely match a person’s
true record. This can happen for a few reasons:
- The search returns the wrong person’s record: In the rare event
- that a different person’s Oregon record shares a birth date and
- name with the person of interest, these records may appear in the
- search result.
+ The search returns the wrong person's record: In rare cases,
+ another person with the same name and birth date may appear in
+ results.
One or more of a person’s cases cannot be found: If the record has
@@ -65,26 +47,27 @@ export default function EditingGuide() {
If any of these issues appear in the record, they can be corrected
with the Editing feature. You can also use the Editing feature to
build a record from scratch and run an analysis without relying on
- OECI at all. Editing is built directly in to the main Search
- feature.
+ OECI at all. Editing is built directly into the main Search feature.
- Enable Editing
+ Enable Editing
- Click the Enable Editing button on the Record Search page:
+ Click the "Enable Editing" button on the Record Search page:
-
+
Enable Editing
-
+
You can now perform the following actions to change the contents of
- the record. These changes exist only temporarily in your session and
- will disappear if you run another search. The edits will persist if
- you navigate between Search and the Manual or other pages on
- recordsponge.com, but they will disappear if you leave the website.
+ the record. Edits are temporary and persist while you navigate the
+ site, but will reset if you run a new search or leave
+ recordsponge.com.
- Add Case
+ Add Case
- Click the Add Case button just below the Summary panel:
+ Click the "Add Case" button just below the Summary panel:
{}} actionName="Add" text="Case" />
@@ -95,10 +78,10 @@ export default function EditingGuide() {
Balance, and Birth Year, all of which are used to provide complete
analysis of the record.
- Edit Case
+ Edit Case
You can edit any of these fields on an existing or newly created
- case by clicking the edit button in the case's header.
+ case by clicking the "Edit" button in the case's header.
{}} actionName="Edit Case" />
@@ -122,8 +105,8 @@ export default function EditingGuide() {
showEditButtons={true}
/>
- Add Charge
-
+
Add Charge
+
Next to the Edit Case{" "}
{}} actionName="Edit Case" /> button is
another Add{" "}
@@ -138,13 +121,13 @@ export default function EditingGuide() {
button. This allows you to add charges to a newly-created or
existing case.
-
- For an existing charge, you can also the information by clicking the
- Edit
+
+ For an existing charge, you can also edit the information by
+ clicking the Edit
{}} actionName="Edit Case" />
button on that charge.
-
+
Both of these operations open a similar panel to allow creating or
editing the charge:
@@ -164,15 +147,19 @@ export default function EditingGuide() {
disposition and a Charge Type. The charge type and disposition
jointly determine the type-eligibility of the charge. See our
complete list of{" "}
-
- {" "}
- Charge Types{" "}
+
+ Charge Types
{" "}
so that you can accurately select a charge type for each new or
edited charge.
-
+
);
}
diff --git a/src/frontend/src/components/Manual/Manual.test.tsx b/src/frontend/src/components/Manual/Manual.test.tsx
index d41aa62ed..50d423bcd 100644
--- a/src/frontend/src/components/Manual/Manual.test.tsx
+++ b/src/frontend/src/components/Manual/Manual.test.tsx
@@ -11,7 +11,7 @@ it("renders", () => {
.create(
-
+ ,
)
.toJSON();
});
@@ -22,16 +22,24 @@ test("the editing guide can be opened and closed", async () => {
render(
-
+ ,
);
+ const part2 = screen.getByLabelText("Part 2: Search Client Records");
+ await user.click(part2);
+
+ const results = screen.getByLabelText("Results");
+ await user.click(results);
+
+ const summary = screen.getByLabelText("Editing Guide");
+
expect(screen.queryByText(/why edit/i)).not.toBeVisible();
- await user.click(screen.getByRole("button"));
+ await user.click(summary);
- expect(screen.queryByText(/why edit/i)).toBeVisible();
+ expect(screen.getByText(/why edit/i)).toBeVisible();
- await user.click(screen.getByRole("button", { name: /editing guide/i }));
+ await user.click(summary);
expect(screen.queryByText(/why edit/i)).not.toBeVisible();
});
diff --git a/src/frontend/src/components/Manual/Sidebar.tsx b/src/frontend/src/components/Manual/Sidebar.tsx
new file mode 100644
index 000000000..255d8654e
--- /dev/null
+++ b/src/frontend/src/components/Manual/Sidebar.tsx
@@ -0,0 +1,78 @@
+import { openDetailsById } from "../common/Accordion";
+
+const sectionLinks = [
+ { title: "General Info", href: "#general-info", subSection: false },
+ { title: "Part 1: OECI Login", href: "#oeci-login", subSection: false },
+ {
+ title: "Part 2: Search Records",
+ href: "#search-records",
+ subSection: false,
+ },
+ { title: "Assumptions", href: "#assumptions", subSection: true },
+ { title: "Search", href: "#search", subSection: true },
+ { title: "Results", href: "#results", subSection: true },
+ { title: "Editing", href: "#editing", subSection: true },
+ { title: "Fines and Fees", href: "#fines-and-fees", subSection: true },
+ {
+ title: "Part 3: Complete Paperwork",
+ href: "#complete-paperwork",
+ subSection: false,
+ },
+ { title: "Expungement", href: "#generate-paperwork", subSection: true },
+ { title: "Financial Obligations", href: "#feewaiver", subSection: true },
+ {
+ title: "Part 4: Obtain Fingerprints",
+ href: "#obtain-fingerprints",
+ subSection: false,
+ },
+ {
+ title: "Part 5: File Paperwork",
+ href: "#file-paperwork",
+ subSection: false,
+ },
+ { title: "FAQs", href: "#faqs", subSection: false },
+];
+
+interface Props {
+ handleSidebarOpen?: () => void;
+}
+
+/**
+ * Table-of-contents navigation for the Manual page.
+ * Opens the target accordion section on click.
+ */
+function Sidebar({ handleSidebarOpen }: Props) {
+ const handleClick = (href: string) => {
+ const id = href.slice(1);
+ openDetailsById(id);
+ handleSidebarOpen?.();
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default Sidebar;
diff --git a/src/frontend/src/components/Manual/index.tsx b/src/frontend/src/components/Manual/index.tsx
index 9c7f56e0d..3c8e484e7 100644
--- a/src/frontend/src/components/Manual/index.tsx
+++ b/src/frontend/src/components/Manual/index.tsx
@@ -1,673 +1,1307 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
import { HashLink as Link } from "react-router-hash-link";
import EditingGuide from "./EditingGuide";
+import Accordion, { openDetailsById } from "../common/Accordion";
+import Sidebar from "./Sidebar";
+import IconButton from "../common/IconButton";
+// import VideoEmbed from "../common/VideoEmbed";
+import Figure from "../common/Figure";
+import useLockBodyScroll from "../../hooks/useLockBodyScroll";
-class Manual extends React.Component {
- componentDidMount() {
+function Manual() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleSidebarOpen = () => {
+ setIsOpen((prev) => !prev);
+ };
+
+ useLockBodyScroll(isOpen);
+
+ useEffect(() => {
+ const handleResize = () => {
+ if (window.innerWidth >= 960) {
+ setIsOpen(false);
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ useEffect(() => {
document.title = "Manual - RecordSponge";
- }
+ }, []);
- render() {
- return (
- <>
-
-
-
Manual
+ /**
+ * Intercepts clicks on in-page `#hash` links
+ * to open the target accordion before scrolling.
+ */
+ const handleArticleClick = (e: React.MouseEvent
) => {
+ const target = e.target as HTMLElement;
+ const anchor = target.closest("a[href^='#']");
+ const href = anchor?.getAttribute("href");
+ if (href) {
+ openDetailsById(href.slice(1));
+ }
+ };
+
+ return (
+
+
+
+
Manual
+
+
+ {/* Mobile Sidebar/Drawer */}
+
+
+
+
+
-
-
-
-
- Introduction
-
-
- RecordSponge is a volunteer-built web application used to
- facilitate the expungement process in Oregon. It is a
- collaboration between{" "}
-
- Code PDX
- {" "}
- and{" "}
-
- Qiu-Qiu Law
-
- . The codebase is published under an open source{" "}
-
- MIT license
-
- .
-
-
- This Manual explains how RecordSponge is used and the process of
- expunging records. It is also published under an open source MIT
- license.
-
-
- As of this writing, it is likely that fewer than{" "}
-
- 2%
- {" "}
- of Oregonians who are eligible to expunge their records have
- done so. Let’s get to work.
-
-
-
-
- General Info
-
-
- Every State has different expungement rules.{" "}
-
- Recent changes
- {" "}
- to Oregon's expungement law make more types of criminal records
- eligible than ever before, and on a much faster timeline.
- Nevertheless, the complexity of the expungement statutes, ORS{" "}
-
- 137.225
- {" "}
- and{" "}
-
- 137.226
-
- . pose a significant barrier for people seeking expungements on
- their own. Few organizations outside of the metropolitan area
- are equipped to perform expungement services, and the market
- rate to hire an attorney is over $1,400 per case .
-
-
- RecordSponge is and always will be free to use.
-
-
- If you would like to use RecordSponge, please contact
- michael@qiu-qiulaw.com.
-
-
-
- Note on Juvenile Records
-
-
- RecordSponge only deals with adult criminal records. Juvenile
- records are eligible on a different basis,{" "}
-
- more info here
-
- .
-
-
- There are generally two ways to expunge a juvenile record:
-
-
-
- The juvenile record is 5+ years old and the client hasn’t
- had subsequent criminal cases.
-
-
- The client requests a hearing and demonstrates that it would
- be in the “best interests of justice” to expunge the record.
-
-
-
-
-
-
- Using RecordSponge
-
- Overview
-
- We ask anyone using the software to be in touch so that we can
- better maintain, scale, and improve our work and community.{" "}
-
- Please complete this contact form
-
- .
-
-
-
- Log in and search records
-
+
+
+ {/* Sidebar */}
+
+
+
+
+ How to use RecordSponge
+
+ RecordSponge is built by{" "}
+
+ Code PDX
+
+ , a volunteer organization. Its codebase is published under the{" "}
+
+ MIT license
+
+ . It is and always will be free to use. Only a small percentage of
+ Oregonians who are eligible to expunge their records have done so.
+ Let's get to work.
+
+
+ We ask anyone using the software to reach out so we can better
+ maintain and improve our work. Prior to using RecordSponge for the
+ first time, please reach out to us at{" "}
+
+ michael@qiu-qiulaw.com
+
+ . Have questions? Check the{" "}
+
+ FAQs
+
+ .
+
+
+
+
+
+
+
+
How it works
+
+ RecordSponge connects directly to the{" "}
+ Oregon eCourt Case Information (OECI)
+ system using your credentials. When you search, it logs into
+ OECI, submits your query, and collects case data from the
+ results using a web scraper — a tool that reads and extracts
+ information from websites.
+
+
+ This scraper then applies an algorithm based on Oregon's
+ expungement statutes (ORS{" "}
+
+ 137.225
+ {" "}
+ and{" "}
+
+ 137.226
+
+ ) to determine eligibility for every case found by OECI.
+
+
Checklist
+
+ Using RecordSponge, the expungement process has 5 steps:
+
+
+
+
+ Log in to OECI
- .
-
-
- No OECI account yet? The demo version has all the same
- features besides the ability to search the OECI database.
- There are examples provided or you can even enter records
- manually.{" "}
-
- Check out the demo
-
- .
+
+
+ You will need an OECI account to search for criminal
+ records.{" "}
+
+ You can purchase a subscription here
+
+ .
+
+
+ No OECI account yet? The demo version has all the same
+ features besides the ability to search the OECI
+ database. Examples are provided, or you can enter
+ records manually.{" "}
+
+ Check out the demo
+
+ .
+
+
-
-
-
- Ensure that{" "}
-
- Assumption
- {" "}
- is met
-
-
-
- Search records
- {" "}
- by name and date of birth
-
-
- Confirm positive search results with Michael:
- michael@qiu-qiulaw.com
-
-
-
- Complete forms
-
- ,{" "}
-
- obtain fingerprints
-
-
-
- Instruct clients to{" "}
-
- file paperwork
- {" "}
- in appropriate courts
-
-
- Mail in fingerprints to Oregon State Police
-
-
-
-
-
- Assumption
-
-
- Before delivering expungement analysis, ensure that this
- assumption is met.
-
-
-
- RecordSponge only has access to online records of Oregon’s
- Circuit courts. However, having an open case or a conviction{" "}
- anywhere within the last 7 years could affect
- eligibility. The accuracy of the expungement analysis depends on
- these assumptions:
-
-
-
- The client does not have any open cases in any court in the
- United States.
-
-
- A person with an open case is not eligible. For example, a
- person with a warrant is ineligible.
-
-
-
-
- The client does not, within the last 7 years, have cases which
- are:
-
- previously expunged
- Federal
- from States besides Oregon
- from Municipal Courts
-
-
- Cases closed more than 7 years ago, in any court, do not
- affect eligibility.
+
+ Search records
+
+
- Traffic ticket sdo not count. However, convictions for
- misdemeanor or felony traffic cases, such as Driving While
- Suspended, count.
+
+ Complete paperwork
+ {" "}
+ for expungement
+
- Previously expunged cases affect expungement eligibility.
- Accordingly, RecoredSponge’s analysis may not be accurate
- if a person has a case previously expunged, and that case
- is from the last 7 years. Note that this rule does not
- prevent a person from filing for expungement multiple
- times within the same 7 year period.
+
+ Obtain fingerprints
+
+
+ Mail to Oregon State Police
+
- Again, beware of convictions for misdemeanor/felony
- traffic violations, e.g. Beaverton Municipal Court (not
- Washington Circuit Court), Troutdale Municipal Court,
- Medford Municipal Court, These courts generally handle
- low-level crimes and especially traffic crimes. We only
- need to worry about misdemeanor and felonies, including
- for Driving While Suspended.
+
+ File paperwork
+ {" "}
+ in appropriate courts
-
-
-
-
-
Questions to always ask
-
-
- Does the client have any open charges in other States or in
- municipal court?
+
+
+
+ Download checklist as PDF
+
+
+
+ If new to RecordSponge, confirm results with Michael Zhang
+ at{" "}
+
+ michael@qiu-qiulaw.com
+
+ .
+
+
+
Access and Limitations
+
+
+ Adult Records Only: RecordSponge only
+ deals with adult criminal records.
+
+
+ Juvenile Records: Juvenile records are
+ eligible on a different basis. They generally require
+ the record to be 5+ years old with no subsequent cases,
+ or a showing that expungement is in the "best interests
+ of justice." (see{" "}
+
+ more info here
+
+ )
+
+
+
+
+
+
+
+
+ To search criminal records, you will need an{" "}
+ Oregon eCourt Case Information (OECI) {" "}
+ account.
+
+ For existing users:
+
+
+ Navigate to the login page by clicking "Search" in the
+ navbar
+
+
+ Search
+
+
-
- Does the client have any criminal convictions in States
- besides Oregon from the last seven years?
+
+ Enter your OECI login credentials and click "Log in to OECI"
+
+
+ Log in to OECI
+
+
-
-
-
- If the Assumption is not met, but you would still like to
- conduct an analysis, or if you have any questions about this
- section, please contact michael@qiu-qiulaw.com.
-
-
-
-
- Search
-
-
- Check out a quick video demonstrating how to search:
-
-
-
-
-
- Enter the person’s first and last name and date of birth into
- the search bar.
-
-
- If the person has a previous name, alias, maiden name, etc.,
- select the option to “Add Alias” and fill out another search.
-
-
- For example, for a search Jane Smith nee Miller, DOB 1/1/1990,
- search Jane Smith, 1/1/1990; then, Add Alias of Jane Miller,
- 1/1/1990.
-
-
-
Search Tips
-
- Courts often input incorrect information, and certain cases
- will not show all results. If an anticipated case does not
- show, also perform a search of the person’s first initial
- followed by a *, the person’s first three letters of their
- last name followed by a *, and their date of birth. For
- example, a search for Michael Zhang with birthdate 5/9/1993
- would be M*, Zhang*, 5/9/1993.
-
-
- Regarding names with two letter starters, e.g. Mc, De, Di, the
- online court system will inconsistently input the two-letter
- starter as following with a space or no space. For example,
- McDonald could be input as Mc Donald (with a space) or
- McDonald (without a space). You would therefore need to search
- under both forms of the name to see which the court system has
- used.
-
-
-
- Search Results
-
-
-
Eligible Now
-
- Eligible
+
+
For new users:
+
+
+
+
+
+
+
+ RecordSponge only analyzes public{" "}
+ Oregon Circuit Court records. However,
+ having an open case or a conviction anywhere within the
+ last 7 years could affect eligibility. To ensure the
+ accuracy of your expungement analysis, please review the
+ following conditions when brought up:
+
+
+
+
+
+
The Scope
+
+ Eligibility may be affected if there are records
+ from the last 7 years in:
+
+
+ Federal or Out-of-State Courts
+
+ Municipal (City) Courts (see{" "}
+
+ Notes on Municipal Courts
+
+ )
+
+
+ Previous Expungements: If you successfully
+ expunged a case within the last 7 years, it
+ may still affect your current eligibility
+
+
+
+
+
+
+
Disqualifying Factors
+
+ The client may be ineligible for expungement if
+ they:
+
+
+
+ Have any open case in any courts in the US
+
+ Have an active warrant
+
+
+
+
+
+
Traffic Violations
+
+
+ Non-Criminal: Standard traffic tickets{" "}
+ do not {" "}
+ affect eligibility
+
+
+ Criminal: Misdemeanor or felony traffic
+ convictions (such as Driving While Suspended){" "}
+ do {" "}
+ count and must be factored into the 7-year
+ lookback period
+
+
+
+
+
+
+
The 7-Year Rule
+
+
+ The Cutoff: Generally, any case closed more
+ than 7 years ago (in any court) will not
+ affect your current eligibility
+
+
+ Multiple Filings: You are allowed to file for
+ expungement multiple times within a 7-year
+ period, provided each specific case meets the
+ necessary criteria
+
+
+
+
+
+
+
+ All search results are based on the assumption that the
+ conditions above are met for the client's case(s).
+
+
+ If the{" "}
+
+ Assumptions
+ {" "}
+ are not met, but you would still like to conduct an
+ analysis, or if you have any questions about this
+ section, please contact{" "}
+
+ michael@qiu-qiulaw.com
+
+ .
+
+
+
Notes on Municipal Courts
+
+ Beware of misdemeanor/felony traffic convictions from
+ municipal courts such as Beaverton Municipal Court
+ (not Washington Circuit Court), Troutdale Municipal
+ Court, and Medford Municipal Court. These courts
+ handle low-level and traffic crimes — only
+ misdemeanors and felonies matter for eligibility,
+ including Driving While Suspended.
+
+
+
+
+
+
+
+ After logging into OECI, find your client's records
+ using the "Search" feature.
+
+
+
+ Court records often contain errors - a client’s name may
+ be misspelled or their date of birth may be incorrect.
+
+
+ The goal is to pull all of the client’s records without
+ pulling records of other persons. You can do this by
+ searching using a "smart" combination of the client’s
+ legal name(s) and date of birth or through a more direct
+ input approach.
+
+
+
+ Consider the following client:
+
+ Date of Birth: 01/01/1900
+
+ Current preferred name: Sam "Mo" Alice Roe-Thomas
+
+
+ Current legal name: Samantha Alice Roe-Thomas
+
+
+ Previous legal name: Samantha Alice Roe, Samantha
+ Alice Thomas
+
+
+ Aliases (not legal name): "Mo" Roe
+
+
+
+
+ Several approaches could be made to locate this client's
+ records.
+
+
+
+
+ Enter the client's name and date of birth, then
+ press the "Search" button. Typically, using the name
+ along with date of birth is enough to narrow the
+ search.
+
+
+ In rare cases, the middle name could also be used.
+
+
+ However, depending on the name's complexity, a more
+ powerful approach might be needed (see{" "}
+
+ "Smart Search"
+
+ ).
+
+
+
+
+
+
+
+ A different approach is to use the asterisk (*) to
+ shorten names. This can be especially useful for
+ long names.
+
+
+
+
+
+
+
+
+ For clients who are difficult to find — often due to
+ incorrect court records — "Smart Search" combines
+ both the Simple Search and Wildcard Search
+ approaches. Using the "Alias" button would let you
+ fill in additional rows.
+
+
+
+
+
Search Tips
+
+ If an expected case is missing even after "smart
+ searching", try searching with{" "}
+
+ aliases
+
+ .
+
+
+ Names with prefixes like Mc, De, or Di may be
+ stored by the court with or without a space. For
+ example, McDonald could be stored as Mc Donald
+ (with a space) or McDonald (without a space).
+ Search both forms to ensure complete results.
+
+
+
+
+
+ To reset or clear the existing search results, simply
+ click "Clear Data".
+
+
+
+ Clients may have aliases from marriage and/or
+ nicknames in court records. Courts may have input
+ names incorrectly (see{" "}
+
+ Search Tips
+ {" "}
+ for more details). The "Alias" feature can let you add
+ additional names for the search filter.
+
+
+
+
+
+
+
+
+
+
+ RecordSponge analyzes records for eligibility on a
+ charge-by-charge basis. After the search is performed, you
+ can see results in the{" "}
+
+ "Search Summary"
+ {" "}
+ panel by default. Every charge can be viewed in greater
+ detail in{" "}
+
+ Full Results
+
+ , or you can view everything on one page via{" "}
+
+ Expanded View
+
+ .
+
+
+
+
+
+ Usually, the search results in the panel will appear as the following:
+
+
+
+
Eligible Now
+
+ Eligible
+
+
+ The specific charge is eligible for expungement if{" "}
+
+ Assumption
+ {" "}
+ is true.
+
+
+
+
+ Eligible on a future date
+
+
+ Eligible Aug 26, 2024
+
+
+ The specific charge is eligible for expungement on
+ the date specified. This is also conditional on{" "}
+
+ Assumption
+ {" "}
+ being true. Having other cases could push out the
+ eligibility date further.
+
+
+ Eligibility date dependent on open charge:
+ Eligible Jun 12, 2022 or 7 years from conviction
+ of open charge
+
+
+ If there is an open charge, the affected charges
+ will show multiple possible eligibility
+ timeframes. Once the open charge is closed then
+ the analysis will update. You can edit the open
+ charges to see how the eligibility will be
+ affected.
+
+
+
+
Ineligible
+
+ Ineligible
+
+
+ The specific charge is not eligible under the
+ current law because it is not "type-eligible." The
+ reason why the charge is not type-eligible is
+ different for each charge. This is not conditional
+ on the{" "}
+
+ Assumptions
+
+ .
+
+
+
+
+ However, there could be cases that may require
+ additional action, of which the following results
+ would be shown:
+
+
+
+
Further Analysis Needed
+
+ Needs More Analysis
+
+
+ Sometimes, there is not enough information on the
+ OECI website to determine whether or not a case is
+ eligible. RecordSponge will then prompt the user
+ to answer questions, and the analysis will update
+ based on those answers.
+
+
+
+
Restitution Owed
+
+ Ineligible If Restitution Owed
+
+
+ RecordSponge can detect if Restitution is
+ discussed in a Case's history, but OECI does not
+ always show whether it is still owed. Cases under
+ this heading will not print unless updated to
+ reflect that Restitution is not owed.
+
+
+ Ask the client directly if they currently owe
+ Restitution on the Case. If Restitution has been
+ paid, edit the Case to remove this status:
+
+
+
+ Select "Enable Editing" on the right below
+ "Search Summary"
+
+
+ Click the editing pencil associated with the
+ Case (not the Charge)
+
+ Select "False" under "Restitution Owed"
+
+
+ Note: Edits are temporary and
+ will revert when leaving RecordSponge. See the{" "}
+
+ Editing Guide
+ {" "}
+ for more details.
+
+
+
+
+
+
+
+ Every charge in "Search Summary" can be seen in
+ greater detail in the subsequent panels.
+
+
+
+ Within these panels, the case number under "Case"
+ will be linked to the OECI page and an explanation
+ for eligibility. This information may help to
+ explain results that are inconsistent with user
+ expectations.
+
+
+ You can also use this to diagnose problems with
+ RecordSponge's analysis, usually related to search
+ results including:
+
+
+
+ Search results contain other people's records (see{" "}
+
+ Smart Search
+
+ )
+
+
+ OECI records incomplete or inaccurate (common with
+ records from before 2005)
+
+
+
+
+
+
+
+ When viewing from the Expanded View, it will pull
+ all of RecordSponge's functionality into the
+ existing page.
+
+
+ The "Summary Results" panel will be separated into
+ "Review Summary" and "Counts". The full results for
+ each charge will be placed within the "Analyze
+ Cases" panel. The "Quick Links" panel contains links
+ that let you quickly navigate to specific cases.
+
+
+
+ At the end of the page past the full results, the
+ button for downloading the analysis report as a PDF
+ as well as the form to generate paperwork will be
+ available.
+
+
+
+
+
+
+
+
+
+
+ Under "Generate Paperwork," any outstanding Circuit Court
+ balance owed by the client will appear (see{" "}
+ Balance due by county in{" "}
+
+ example search result
+
+ ). Oregon law gives judges broad discretion to reduce or
+ waive non-restitution fines and fees.
+
+
+ To request a reduction or waiver, applicants must file a
+ Motion to Modify Financial Obligations (see{" "}
+
+ Financial Obligations
+ {" "}
+ for details) in the court where the balance is owed.
+
+
+
Exclusions and Limitations
+
+ "Balance due by county" only shows fines and fees owed
+ in Circuit Court. It does not include municipal courts
+ (where many traffic violations are handled) or
+ restitution that has been sent to a collection agency.
+ Restitution is not eligible for waiver and may not
+ appear.
+
+
+
-
- The specific charge is eligible for expungement if{" "}
-
- Assumption
- {" "}
- is true.
-
-
-
-
Eligible on a future date
-
- Eligible Aug 26, 2024
+
+
+
+
+
+
+ When a search is finished, the "Search Summary" panel
+ will appear with cases that may or may not be eligible
+ for expungement.
+
+
+
+ If a client has eligible charges, the "Search Summary"
+ will display a button to "Generate Paperwork".
+
+
+
+ Clicking the button will redirect you to a form for
+ entering the information needed to file the expungement.
+
+
+
+ After you input the information, you can download the
+ expungement paperwork from RecordSponge via the
+ "Download Expungement Packet" button.
+
+
+ Download Expungement Packet (N charges)
+
+
+ The download is a .zip file containing all eligible
+ cases, with one PDF per case. Each PDF must be printed
+ and signed by the client.
+
+
+ RecordSponge will also generate a Request form to Oregon
+ State Police. You will need to complete this form
+ manually and mail a completed copy to Oregon State
+ Police at:
+
+ Oregon State Police, CJIS – Unit 11
+
+ ATTN: SET ASIDE
+
+ P.O. Box 4395
+
+ Portland, OR 97208-4395
+
+
+
+
+ You will also need to{" "}
+
+ obtain fingerprints
+ {" "}
+ to include with this mailing (see Part 4).
+
+
+
+
+
+
+ Motions to Modify Financial Obligation
+
+
+ RecordSponge can also help reduce fines and fees on
+ criminal cases. Oregon Circuit Courts other than
+ Multnomah County now accept a standardized Motion for
+ this purpose. A copy of this Motion is available{" "}
+
+ here
+
+ .
+
+
+
+ A Motion must be filed for each case, but the content
+ can be identical
+
+ Restitution cannot be waived
+ No separate filing fee
+
+ The Motion is granted entirely at the Court's
+ discretion
+
+
+ These Motions are filed and served the same way as
+ expungement motions (see{" "}
+
+ File Paperwork
+
+ )
+
+
+
+ On the{" "}
+
+ Generate Paperwork
+ {" "}
+ page, click the "Motions to Waive Fees" button and
+ complete the additional questions in the form.
+
+
+ {"Motions to Waive Fees (N cases) >>"}
+
+
+
-
- The specific charge is eligible for expungement on the date
- specified. This is also conditional on{" "}
-
- Assumption
+
+
+
+ After completing your paperwork (see{" "}
+
+ Part 3
+
+ ), you will need to obtain fingerprints and mail them along
+ with the OSP request form. You will also need to{" "}
+
+ file paperwork
{" "}
- being true. Having other cases could push out the eligibility
- date further.
-
-
- Eligibility date dependent on open charge: Eligible Jun 12,
- 2022 or 7 years from conviction of open charge
-
-
- If there is an open charge, the affected charges will show
- multiple possible eligibity timeframes. Once the open charge
- is closed then the analysis will update. You can edit the open
- charges to see how the eligibility will be affected.
-
-
-
-
Further Analysis Needed
-
- Needs More Analysis
-
-
- Sometimes, there is not enough information on the OECI website
- to determine whether or not a case is eligible. RecordSponge
- will then prompt the user to answer questions, and the
- analysis will update based on those answers.
-
-
-
-
Restitution Owed
-
- Ineligible If Restitution Owed
-
-
- There is not enough information on the OECI website to
- determine if Restitution is owed on this Case. Cases under
- this heading will not print unless updated to reflect that
- Restitution is not owed.
+ with the appropriate courts (see Part 5).
-
- RecordSponge can detect if Restitution is discussed in a
- Case's history of events, but OECI does not always show
- whether Restitution is still owed.
+
+ Fingerprints
+
+
+ Fingerprints must be printed or inked directly onto cardstock
+ — digital prints are not accepted. These can be obtained at
+ sheriff's offices or fingerprinting services.
-
- Ask the client directly if they currently owe Restitution on
- the Case. If Restitution has been paid, Edit the Case to
- remove this status:
+
+ Another option is to do it yourself with the following
+ materials:
-
-
- Select “Enable Editing” (see the{" "}
-
- editing guide
- {" "}
- below)
+
+
+ Lee Inkless Fingerprint Pad (available on Amazon), $17 for a
+ 3-pack, which serves several hundred people
-
- Click the editing pencil associated with the Case (not the
- Charge)
+
+ Fingerprint Cards, Applicant FD-258 (available on Amazon),
+ $21 for a 50 pack
-
- Select “False” under “Restitution Owed"
+
+
+
+ Included in your expungement packet should be a form titled:
+ "Oregon State Police REQUEST FOR SET ASIDE CRIMINAL RECORD
+ CHECK."
+
+ Fill out the sections:
+
+ "Other Names You are Known By"
+ "Circuit or Municipal Court"
+
+ Check the box corresponding to whether you are seeking an
+ expungement for a conviction or only arrests.
-
-
-
-
Ineligible
-
- Ineligible
-
+
- The specific charge is not eligible under the current law
- because it is not “type-eligible.” The reason why the charge
- is not type-eligible is different for each charge. This is not
- conditional on the Assumption.
+ If you are seeking expungement of at least one conviction, you
+ will need to include a check or money order made out to
+ "Oregon State Police" for $33.
-
-
-
- Motions to Modify Financial Obligation
-
- Oregon Circuit Courts other than Multnomah County now accept a
- form Motion to reduce fines and fees owed on criminal cases. A
- copy of this Motion is available{" "}
-
+ OSP mailing address
+ {" "}
+ noted in Part 3.
+
+
+
+
+ Ensure you have completed your paperwork (see{" "}
+
- here.
-
+ Part 3
+
+ ) before filing. These steps apply to both expungement motions
+ and{" "}
+
+ Motions to Modify Financial Obligation
+
+ .
-
-
-
- After producing a complete record analysis and verifying all the
- information in it is correct, proceed to next steps:
-
-
-
-
- File for Expungement
-
-
-
- Complete expungement paperwork using RecordSponge
-
-
- If a client has eligible charges, the Summary panel will display
- a button to Generate Paperwork. Click the button and you will be
- directed to input identifying information. Complete all fields.
-
-
- After you input the information, RecordSponge will generate a
- .zip file with PDFs of the expungement paperwork for all of the
- charges, with one PDF file for each case that has eligible
- charges.
-
-
- RecordSponge will also generate a Request form to Oregon State
- Police. You will need to complete this form manually and mail a
- completed copy to Oregon State Police at:
-
- Oregon State Police, CJIS – Unit 11
-
- ATTN: SET ASIDE
-
- P.O. Box 4395
-
- Portland, OR 97208-4395
-
-
-
-
- Obtain Fingerprints
-
-
- Obtain fingerprints printed or inked directly onto cardstock.
- Digital fingerprints are not accepted as part of this process.
- Fingerprints printed onto cardstock can be obtained at sheriff's
- offices or fingerprinting services.
-
-
- Another option is to do it yourself with the following
- materials:
-
-
-
- Lee Inkless Fingerprint Pad (available on Amazon), $17 for 3
- pack which can serve several hundred people
-
-
- Fingerprint Cards, Applicant FD-258 (available on Amazon), $21
- for a 50 pack
-
-
-
-
- Included in your expungement packet should be a form titled:
- "Oregon State Police REQUEST FOR SET ASIDE CRIMINAL RECORD
- CHECK."
-
-
- Fill out the sections:
-
- 1. "Other Names You are Known By"
-
- 2. "Circuit or Municipal Court"
-
- 3. Check the box corresponding to whether you are seeking an
- expungement for a conviction or only arresets.
-
-
- If you are seeking expungement of at least one conviction, you
- will need to include a check or money order made out to "Oregon
- State Police" for $33.
-
-
- File Paperwork
-
-
-
- You will need to file the paperwork with the courthouse in
- each county in which you have cases you wish to expunge. File
- with the Clerk of court. There should be no filing fee.
-
- Request two copies from the Clerk.
-
- Serve the District Attorney’s office with one of these copies.
-
-
- Next Steps
-
-
- In the vast majority of cases, your expungement will process
- without objection from the State. If you receive an
- objection from the District Attorney, please email us at
- michael@qiu-qiulaw.com immediately so that we can assist you.
- Even if we do not represent you in court, we will still be
- able to assist you.
-
-
- By law, the District Attorney is required to respond within
- four months after you file. If you receive communication
- during this time from the District Attorney that you would
- like to review with us, please email us at roe@qiu-qiulaw.com
-
-
- Once your expungement is processed, you will receive paper
- confirmation from the court informing you that your record was
- expunged. You should keep copies of this confirmation and make
- electronic copies – obtaining copies of expunged documents is
- extremely costly.
-
-
- After your expungement has been processed, ensure that
- background check companies receive notice. Expungement
- Clearinghouse is a free service that notifies major background
- checking companies of your expungement. This is available at{" "}
-
+
+
+
- https://www.continuingjustice.org/our-projects/criminal-database-update/
-
-
-
-
-
-
- >
- );
- }
+
+
A: I receive this question more than any
+ other, which is why I’m addressing it first. Every single
+ time I’ve gotten the question from a user, the solution
+ has always been the same: the search inputs were
+ different. As explained in{" "}
+
+ Search
+
+ , court records are sometimes incomplete or inaccurate, so
+ we recommend "Smart Searching" to capture all (but only
+ applicable) records. If you actually get different records
+ when using the same search, please email me at{" "}
+
+ michael@qiu-qiulaw.com
+
+ .
+
+
+
+
+
+
+
+
+
+
+ );
}
export default Manual;
diff --git a/src/frontend/src/components/PartnerTable/index.tsx b/src/frontend/src/components/PartnerTable/index.tsx
index fb151406b..a93a2da02 100644
--- a/src/frontend/src/components/PartnerTable/index.tsx
+++ b/src/frontend/src/components/PartnerTable/index.tsx
@@ -52,7 +52,7 @@ function PartnerElement({ partner, useAccordionSection }: PartnerElementProps) {
The majority of court fees are subject to waiver for income-qualified
individuals who complete the waiver form.{" "}
-
+
Learn More
diff --git a/src/frontend/src/components/RecordSearch/Assumptions/index.tsx b/src/frontend/src/components/RecordSearch/Assumptions/index.tsx
index a3aa7af6a..7612f31ed 100644
--- a/src/frontend/src/components/RecordSearch/Assumptions/index.tsx
+++ b/src/frontend/src/components/RecordSearch/Assumptions/index.tsx
@@ -26,7 +26,7 @@ export default function Assumptions() {
-
+
Learn more in the Manual
diff --git a/src/frontend/src/components/common/Accordion/Accordion.test.tsx b/src/frontend/src/components/common/Accordion/Accordion.test.tsx
new file mode 100644
index 000000000..79404693d
--- /dev/null
+++ b/src/frontend/src/components/common/Accordion/Accordion.test.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import "@testing-library/jest-dom";
+import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import Accordion from "./Accordion";
+
+afterEach(() => {
+ window.location.hash = "";
+});
+
+test("renders with title and children hidden by default", () => {
+ render(Content here );
+
+ expect(screen.getByLabelText("Test Section")).toBeInTheDocument();
+ expect(screen.queryByText("Content here")).not.toBeVisible();
+});
+
+test("opens and closes on click", async () => {
+ const user = userEvent.setup();
+ render(Inner content );
+
+ const summary = screen.getByLabelText("Toggle Me");
+
+ await user.click(summary);
+ expect(screen.getByText("Inner content")).toBeVisible();
+
+ await user.click(summary);
+ expect(screen.queryByText("Inner content")).not.toBeVisible();
+});
+
+test("renders open when defaultOpen is set", () => {
+ render(
+
+ Visible content
+ ,
+ );
+
+ expect(screen.getByText("Visible content")).toBeVisible();
+});
+
+test("renders with qna type", () => {
+ render(
+
+ Answer here
+ ,
+ );
+
+ expect(screen.getByText(/Q:/)).toBeInTheDocument();
+ expect(screen.getByLabelText("Is this a question?")).toBeInTheDocument();
+});
+
+test("opens when URL hash matches id", () => {
+ window.location.hash = "#my-section";
+
+ render(
+
+ Hash content
+ ,
+ );
+
+ expect(screen.getByText("Hash content")).toBeVisible();
+});
+
+test("does not open when URL hash does not match id", () => {
+ window.location.hash = "#other-section";
+
+ render(
+
+ Hash content
+ ,
+ );
+
+ expect(screen.queryByText("Hash content")).not.toBeVisible();
+});
+
+test("opens on hashchange event", () => {
+ render(
+
+ Hash content
+ ,
+ );
+
+ expect(screen.queryByText("Hash content")).not.toBeVisible();
+
+ act(() => {
+ window.location.hash = "#my-section";
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ });
+
+ expect(screen.getByText("Hash content")).toBeVisible();
+});
+
+test("hash match opens ancestor details elements", () => {
+ window.location.hash = "#child";
+
+ render(
+
+
+ Nested content
+
+ ,
+ );
+
+ const parent = screen.getByLabelText("Parent").closest("details")!;
+ expect(parent).toHaveAttribute("open");
+ expect(screen.getByText("Nested content")).toBeVisible();
+});
diff --git a/src/frontend/src/components/common/Accordion/Accordion.tsx b/src/frontend/src/components/common/Accordion/Accordion.tsx
new file mode 100644
index 000000000..1335a12a2
--- /dev/null
+++ b/src/frontend/src/components/common/Accordion/Accordion.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useRef } from "react";
+import { openDetailsAncestors } from "./openDetails";
+
+interface Props {
+ title: string;
+ type?: "qna";
+ id?: string;
+ defaultOpen?: boolean;
+ children: React.ReactNode;
+}
+
+/**
+ * Collapsible content section built on the native `` element.
+ * Automatically opens when the URL hash matches its `id`, including nested accordions.
+ * Set `type` to `"qna"` for a Q&A-style header.
+ */
+function Accordion({ title, type, id, defaultOpen, children }: Props) {
+ const detailsRef = useRef(null);
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ const details = detailsRef.current;
+ if (!details) return;
+
+ const handleToggle = () => {
+ if (details.open && contentRef.current) {
+ contentRef.current.style.animation = "none";
+ requestAnimationFrame(() => {
+ if (contentRef.current) {
+ contentRef.current.style.animation = "";
+ }
+ });
+ }
+ };
+
+ details.addEventListener("toggle", handleToggle);
+ return () => details.removeEventListener("toggle", handleToggle);
+ }, []);
+
+ useEffect(() => {
+ if (!id) return;
+
+ const openIfHashMatches = () => {
+ if (window.location.hash === `#${id}` && detailsRef.current) {
+ openDetailsAncestors(detailsRef.current);
+ }
+ };
+
+ openIfHashMatches();
+ window.addEventListener("hashchange", openIfHashMatches);
+ return () => window.removeEventListener("hashchange", openIfHashMatches);
+ }, [id]);
+
+ return (
+
+ {type === "qna" ? (
+
+ Q: {title}
+
+ ) : (
+
+ {title}
+
+ )}
+
+
+ );
+}
+
+export default Accordion;
diff --git a/src/frontend/src/components/common/Accordion/index.ts b/src/frontend/src/components/common/Accordion/index.ts
new file mode 100644
index 000000000..5dc93a101
--- /dev/null
+++ b/src/frontend/src/components/common/Accordion/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./Accordion";
+export { openDetailsById, openDetailsAncestors } from "./openDetails";
diff --git a/src/frontend/src/components/common/Accordion/openDetails.ts b/src/frontend/src/components/common/Accordion/openDetails.ts
new file mode 100644
index 000000000..94a6e729d
--- /dev/null
+++ b/src/frontend/src/components/common/Accordion/openDetails.ts
@@ -0,0 +1,21 @@
+/**
+ * Opens all `` ancestors of the given element
+ * so nested accordion content becomes visible.
+ */
+export function openDetailsAncestors(el: HTMLElement) {
+ let current: HTMLElement | null = el;
+ while (current) {
+ if (current.tagName === "DETAILS") {
+ (current as HTMLDetailsElement).open = true;
+ }
+ current = current.parentElement;
+ }
+}
+
+/**
+ * Finds an element by `id` and opens all its `` ancestors.
+ */
+export function openDetailsById(id: string) {
+ const el = document.getElementById(id);
+ if (el) openDetailsAncestors(el);
+}
diff --git a/src/frontend/src/components/common/Figure.tsx b/src/frontend/src/components/common/Figure.tsx
new file mode 100644
index 000000000..c2b609a35
--- /dev/null
+++ b/src/frontend/src/components/common/Figure.tsx
@@ -0,0 +1,23 @@
+interface Props {
+ src: string;
+ alt: string;
+ caption: string;
+ className?: string;
+ imgClassName?: string;
+ id?: string;
+}
+
+/**
+ * Renders an image with a centered caption,
+ * used for screenshots and diagrams in the Manual.
+ */
+function Figure({ src, alt, caption, className, imgClassName, id }: Props) {
+ return (
+
+
+ {caption}
+
+ );
+}
+
+export default Figure;
diff --git a/src/frontend/src/components/common/VideoEmbed.tsx b/src/frontend/src/components/common/VideoEmbed.tsx
new file mode 100644
index 000000000..3d424a166
--- /dev/null
+++ b/src/frontend/src/components/common/VideoEmbed.tsx
@@ -0,0 +1,26 @@
+interface Props {
+ src: string;
+ title: string;
+}
+
+/**
+ * Responsive 16:9 iframe wrapper for embedding YouTube videos.
+ */
+function VideoEmbed({ src, title }: Props) {
+ return (
+
+
+
+ );
+}
+
+export default VideoEmbed;
diff --git a/src/frontend/src/hooks/useLockBodyScroll.ts b/src/frontend/src/hooks/useLockBodyScroll.ts
new file mode 100644
index 000000000..0a7f2d2a7
--- /dev/null
+++ b/src/frontend/src/hooks/useLockBodyScroll.ts
@@ -0,0 +1,20 @@
+import { useEffect } from "react";
+
+/**
+ * Prevents body scrolling when `locked` is `true`.
+ * Restores scroll on unlock or unmount.
+ */
+function useLockBodyScroll(locked: boolean) {
+ useEffect(() => {
+ if (locked) {
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "";
+ }
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, [locked]);
+}
+
+export default useLockBodyScroll;
diff --git a/src/frontend/src/styles/_globals.scss b/src/frontend/src/styles/_globals.scss
index ba288fe53..fd8b4f930 100644
--- a/src/frontend/src/styles/_globals.scss
+++ b/src/frontend/src/styles/_globals.scss
@@ -526,3 +526,147 @@ $lightest-blue2: #e8f2ff;
left: 50%;
transform: translate(-50%, -50%);
}
+
+// Consistent spacing for Manual page content
+.manual-content {
+ p { margin-bottom: 0.5rem; }
+ h2 { margin-bottom: 1rem; }
+ h3 { margin-bottom: 0.5rem; }
+ h4 { margin-bottom: 0.5rem; }
+ ul, ol { margin-bottom: 0; }
+ li { margin-bottom: 0.5rem; }
+}
+
+//style for details/summary open and close animation scoped to Accordion
+.accordion-details {
+ > summary {
+ cursor: pointer;
+ list-style: none;
+ }
+
+ > summary::before {
+ content: "▶";
+ display: inline-block;
+ transition: transform 0.2s;
+ margin-right: 0.5em;
+ }
+
+ &[open] > summary::before {
+ transform: rotate(90deg);
+ }
+
+ &[open] > .content {
+ animation: slideDown 0.3s ease-out;
+ }
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.gap4 {
+ gap: 1rem;
+}
+
+.scroll-mt-20 {
+ scroll-margin-top: 5rem;
+}
+
+@media screen and (min-width: 960px) {
+ .sticky-l {
+ position: sticky;
+ top: 4.5rem;
+ }
+}
+
+@media screen and (max-width: 959px) {
+ //style for mobile drawer for Manual
+ .mobile-nav-btn {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: 50%;
+ background: $blue;
+ color: white;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ box-shadow: 0 4px 4px rgba(0,0,0,0.3);
+ font-size: 1.5rem;
+ border: none;
+ z-index: 111;
+ }
+
+ .mobile-sidebar {
+ position: fixed;
+ top: 4.5rem;
+ right: -100%;
+ width: 100%;
+ height: 100dvh;
+ background: white;
+ box-shadow: -2px 0 8px rgba(0,0,0,0.3);
+ overflow-y: auto;
+ padding: 1rem;
+ transition: right 0.3s ease;
+ z-index: 110;
+ }
+
+ .mobile-sidebar.open {
+ right: 0;
+ }
+
+ .sidebar-hamburger {
+ width: 1.2rem;
+ height: 1rem;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .sidebar-hamburger span {
+ display: block;
+ height: 2px;
+ background: white;
+ border-radius: 1px;
+ transition: all 0.3s ease;
+ position: absolute;
+ width: 100%;
+ }
+
+ .sidebar-hamburger span:nth-child(1) {
+ top: 0;
+ }
+
+ .sidebar-hamburger span:nth-child(2) {
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ .sidebar-hamburger span:nth-child(3) {
+ bottom: 0;
+ }
+
+ .sidebar-hamburger.open span:nth-child(1) {
+ top: 50%;
+ transform: translateY(-50%) rotate(45deg);
+ }
+
+ .sidebar-hamburger.open span:nth-child(2) {
+ opacity: 0;
+ }
+
+ .sidebar-hamburger.open span:nth-child(3) {
+ bottom: 50%;
+ transform: translateY(50%) rotate(-45deg);
+ }
+}