diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..c946c747
Binary files /dev/null and b/.DS_Store differ
diff --git a/README.md b/README.md
index 39691fcd..04c8bad1 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,46 @@
-Add a readme for your dashboard here. Include content overview, data citations, and any relevant technical details.
\ No newline at end of file
+# ADU Query Tool
+
+Enter a Philadelphia address to see its zoning and whether an Accessory Dwelling Unit (ADU) is permitted.
+
+## Purpose
+
+* **Users:** Homeowners and developers
+* **Decision:** Can I build an ADU on this property?
+* **Data:** Zoning base districts, zoning overlays, historic register, and the L&I ADU requirements
+
+## How it works
+
+1. Search an address; the map drops a pin.
+2. The popup checks base district, overlays and historic status automatically.
+3. It follows the L&I flowchart, asking for structure type and lot size.
+4. If permitted, confirm the Additional Code Requirements checklist.
+
+## Sources
+
+All data accessed October 16, 2026.
+
+* **ADU rules:** City of Philadelphia, Department of Licenses and Inspections. (2022). *Accessory Dwelling Unit Checklist* (PZ_004_INF, rev. 3/8/2022). https://www.phila.gov/media/20220308130807/PZ_004_INF_Accessory-Dwelling-Unit-Checklist-rev-3.8.2022.pdf
+* **Zoning base districts** (`Zoning_BaseDistricts`): Philadelphia City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org
+* **Zoning overlays** (`Zoning_Overlays`): Philadelphia City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org
+* **Historic districts** (`historicdistricts_local`): Philadelphia Historical Commission / City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org/datasets/philadelphia-registered-historic-districts (last updated 2017)
+* **Historic sites** (`historic_sites_philreg`): Philadelphia Historical Commission / City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org/datasets/philadelphia-registered-historic-sites (last updated 2017)
+* **Basemap and geocoding:** Mapbox
+* **Colors** Color Brewer 2.0
+
+## Tech
+
+Mapbox GL JS, vanilla JavaScript, HTML, CSS.
+
+## Run locally
+
+1. Add your Mapbox access token in `js/main.js`.
+2. Open `index.html` with a local server (e.g. VS Code Live Server).
+
+## Limits
+
+* Historic status, structure type and lot size need user confirmation.
+* Informational only, not legal advice. L&I makes the final determination.
+
+## Credits
+
+Built by Chloe Alimurong. Code written with assistance from Claude (Anthropic).
\ No newline at end of file
diff --git a/adu-query-tool/.DS_Store b/adu-query-tool/.DS_Store
new file mode 100644
index 00000000..39a61bd9
Binary files /dev/null and b/adu-query-tool/.DS_Store differ
diff --git a/adu-query-tool/css/styles.css b/adu-query-tool/css/styles.css
new file mode 100644
index 00000000..d66e3d88
--- /dev/null
+++ b/adu-query-tool/css/styles.css
@@ -0,0 +1,101 @@
+html {
+ font-size: 22px;
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ font-family: Didot, serif;
+}
+
+main {
+ height: 100%;
+}
+
+.map-section {
+ position: relative; /* lets the search card and legend sit on top of the map */
+ height: 100%;
+}
+
+.map-container {
+ position: absolute;
+ inset: 0; /* fills .map-section */
+}
+
+.map {
+ width: 100%;
+ height: 100%;
+}
+
+.mapboxgl-popup {
+ z-index: 20;
+}
+
+/* Search card */
+
+.map-search-controls {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ z-index: 10; /* above the map */
+ width: min(560px, calc(100% - 32px));
+ box-sizing: border-box;
+ margin: 0;
+ padding: 16px 18px;
+ background: #fff;
+ border: 0;
+ border-radius: 8px;
+ box-shadow: 0 1px 6px rgb(0 0 0);
+}
+
+.search-title {
+ margin: 0 0 4px;
+ font-size: 1.15rem;
+ line-height: 1.2;
+}
+
+.search-help {
+ margin: 0 0 8px;
+ font-size: 0.85rem;
+ color: #555;
+}
+
+.map-search-controls input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 12px 14px; /* taller box */
+ font-size: 1.1rem; /* bigger typed text */
+}
+
+.map-search-controls,
+.map-search-controls input {
+ font-family: Didot, "Bodoni MT", serif;
+}
+
+.mapboxgl-popup button,
+.mapboxgl-popup input {
+ font-family: inherit;
+}
+
+/* keeps the legend for screen readers without showing it */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.mapboxgl-popup-content {
+ padding: 16px 18px;
+ border-radius: 10px;
+ box-shadow: 0 4px 16px rgb(0 0 0 / 35%);
+}
+
+.mapboxgl-popup-close-button {
+ font-size: 22px;
+ padding: 4px 10px;
+}
diff --git a/mockup/img/location.png b/adu-query-tool/img/location.png
similarity index 100%
rename from mockup/img/location.png
rename to adu-query-tool/img/location.png
diff --git a/mockup/img/location.svg b/adu-query-tool/img/location.svg
similarity index 100%
rename from mockup/img/location.svg
rename to adu-query-tool/img/location.svg
diff --git a/adu-query-tool/index.html b/adu-query-tool/index.html
new file mode 100644
index 00000000..8eb92dfe
--- /dev/null
+++ b/adu-query-tool/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ ADU Query
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/adu-query-tool/js/.RData b/adu-query-tool/js/.RData
new file mode 100644
index 00000000..5cf65efc
Binary files /dev/null and b/adu-query-tool/js/.RData differ
diff --git a/adu-query-tool/js/.Rhistory b/adu-query-tool/js/.Rhistory
new file mode 100644
index 00000000..544b09ea
--- /dev/null
+++ b/adu-query-tool/js/.Rhistory
@@ -0,0 +1,111 @@
+#| label: setup
+#| echo: false
+#| message: false
+library(tidyverse)
+library(tidycensus)
+library(sf)
+library(caret)
+library(spdep)
+library(nngeo)
+options(scipen = 999)
+#| label: helpers
+check_na <- function(formula, df) {
+vars <- all.vars(formula)
+bad <- vars[sapply(vars, function(v) any(is.na(df[[v]])))]
+if (length(bad) > 0) {
+stop("These variables have NAs: ", paste(bad, collapse = ", "),
+". Fill them or choose another variable (Rule 1).")
+}
+}
+# 10-fold CV RMSE, same folds every time
+cv_rmse <- function(formula, data) {
+df <- if (inherits(data, "sf")) st_drop_geometry(data) else data
+check_na(formula, df)
+set.seed(5080)
+fit <- train(formula, data = df, method = "lm",
+trControl = trainControl(method = "cv", number = 10))
+fit$results$RMSE
+}
+# Each house's prediction from the fold where it was held out
+cv_pred <- function(formula, data) {
+df <- if (inherits(data, "sf")) st_drop_geometry(data) else data
+check_na(formula, df)
+set.seed(5080)
+fit <- train(formula, data = df, method = "lm",
+trControl = trainControl(method = "cv", number = 10,
+savePredictions = "final"))
+fit$pred %>% arrange(rowIndex) %>% pull(pred)
+}
+# Chicago Data Portal (Socrata) -> sf. `where` is a SoQL filter.
+socrata_get <- function(dataset, where, select = NULL, limit = 200000) {
+where <- str_squish(where)
+q <- paste0("$where=", URLencode(where, reserved = TRUE), "&$limit=", limit)
+if (!is.null(select)) q <- paste0(q, "&$select=", URLencode(str_squish(select), reserved = TRUE))
+url <- paste0("https://data.cityofchicago.org/resource/", dataset, ".geojson?", q)
+tmp <- tempfile(fileext = ".geojson")
+resp <- tryCatch(download.file(url, tmp, quiet = TRUE), error = function(e) e)
+if (inherits(resp, "error")) stop("Portal request failed. Open this URL in a browser to see why:\n", url)
+st_read(tmp, quiet = TRUE)
+}
+#| label: load
+train <- read.csv("~/GitHub/MUSA-5080-portfolio/data/chicago_train.csv")
+test <- read.csv("~/GitHub/MUSA-5080-portfolio/data/chicago_test.csv")
+all_sales <- bind_rows(
+read_csv("~/GitHub/MUSA-5080-portfolio/data/chicago_train.csv", show_col_types = FALSE) %>% mutate(split = "train"),
+read_csv("~/GitHub/MUSA-5080-portfolio/data/chicago_test.csv", show_col_types = FALSE) %>% mutate(split = "test")
+) %>%
+st_as_sf(coords = c("lon", "lat"), crs = 4326) %>%
+st_transform(3435) # Illinois East State Plane, feet
+all_sales %>% st_drop_geometry() %>% count(split)
+#| label: part-1
+train_new <- train
+# sale price < $1
+train_sp <- train %>%
+filter(sale_price<=1)
+# num_fireplace > 4 -> mansion
+train_fp <- train %>%
+filter(num_fireplaces > 4)
+# lot size > 3 acres
+train_ls <- train %>%
+filter(building_000sf > 3)
+# sale price > $ 1 million dollars
+train_spm <- train %>%
+filter(sale_price>1000000)
+# Plot 1: sale price <= $1
+ggplot(train, aes(x = building_000sf, y = sale_price)) +
+geom_point(alpha = 0.3, color = "grey60") +
+geom_point(data = train_sp, color = "red", size = 2) +
+scale_y_log10(labels = scales::dollar) +
+labs(x = "Living area (sq ft)", y = "Sale price (log)",
+title = "Sales of $1 or less") +
+theme_minimal()
+# Plot 2: fireplaces > 4
+ggplot(train, aes(x = num_fireplaces, y = sale_price)) +
+geom_point(alpha = 0.3, color = "grey60") +
+geom_point(data = train_fp, color = "red", size = 2) +
+scale_y_continuous(labels = scales::dollar) +
+labs(x = "Number of fireplaces", y = "Sale price",
+title = "Homes with more than 4 fireplaces") +
+theme_minimal()
+# Plot 3: lot size > 3 acres
+ggplot(train, aes(x = building_000sf, y = sale_price)) +
+geom_point(alpha = 0.3, color = "grey60") +
+geom_point(data = train_ls, color = "red", size = 2) +
+scale_y_continuous(labels = scales::dollar) +
+labs(x = "Living area (sq ft)", y = "Sale price",
+title = "Lots larger than 3 acres") +
+theme_minimal()
+# Plot 4: sale price > $1M
+ggplot(train, aes(x = building_000sf, y = sale_price)) +
+geom_point(alpha = 0.3, color = "grey60") +
+geom_point(data = train_spm, color = "red", size = 2) +
+scale_y_continuous(labels = scales::dollar) +
+labs(x = "Living area (sq ft)", y = "Sale price",
+title = "Sales over $1 million") +
+theme_minimal()
+View(all_sales)
+#| label: part-2
+m1 <- lm(sale_price ~ building_000sf + building_age + lotsize_acre)
+#| label: part-2
+m1 <- lm(sale_price ~ building_000sf + building_age + lotsize_acre, data = all_sales)
+summary(m1)
diff --git a/adu-query-tool/js/main.js b/adu-query-tool/js/main.js
new file mode 100644
index 00000000..40a833d6
--- /dev/null
+++ b/adu-query-tool/js/main.js
@@ -0,0 +1,872 @@
+/* global mapboxgl */
+mapboxgl.accessToken = 'pk.eyJ1IjoiY2hsb2VhbGltdXJvbmciLCJhIjoiY210dWR0cjcyMDBiNjM0cHFvN2p4b2RpMiJ9.EHtMZkiC7TklHIn836JPoQ';
+
+// Data sources
+
+const ZONING_URL =
+ 'https://services.arcgis.com/fLeGjb7u4uXqeF9q/arcgis/rest/services/Zoning_BaseDistricts/FeatureServer/0/query';
+const OVERLAY_URL =
+ 'https://services.arcgis.com/fLeGjb7u4uXqeF9q/arcgis/rest/services/Zoning_Overlays/FeatureServer/0/query';
+const CARTO_SQL = 'https://phl.carto.com/api/v2/sql';
+
+// distanceM: 0 = point-in-polygon (district tables); >0 = search radius in meters (point tables)
+// columns: what to SELECT; describe: turns a row into the text shown in the popup
+const HISTORIC_TABLES = [
+ {
+ table: 'historicdistricts_local',
+ columns: 'name',
+ distanceM: 0,
+ describe: (r) => `Historic district${r.name ? `: ${r.name}` : ''}`,
+ },
+ {
+ table: 'historic_sites_philreg',
+ columns: 'loc, district',
+ distanceM: 10,
+ describe: (r) =>
+ `Historic site${r.loc ? `: ${r.loc}` : ''}${r.district ? ` (${r.district} district)` : ''}`,
+ },
+];
+
+const MIN_ZONING_ZOOM = 15; // do not request zoning when zoomed out further than this
+let zoningVisible = true; // controlled by the legend checkbox
+
+// field names confirmed against the live services
+const BASE_FIELDS = ['long_code', 'code']; // e.g. 'RSA-5', falling back to 'RSA5'
+const OVERLAY_FIELDS = ['overlay_symbol']; // e.g. '/NE'; supplemental controls are '[N/A]'
+
+// ADU rules from the L&I checklist flowchart
+const BLOCK_OVERLAYS_ALWAYS = ['FNE', 'NE']; // not permitted, even for historic structures
+const BLOCK_OVERLAYS_OTHER = ['FDO', 'VDO', 'NDO', 'EDO']; // not permitted unless historic
+const ALLOWED_BASE = ['RSA-5', 'CMX-1'];
+// plain-language names (from the L&I checklist and the Philadelphia zoning code)
+const OVERLAY_NAMES = {
+ FNE: 'Far Northeast',
+ NE: 'Northeast',
+ FDO: 'Fourth District',
+ VDO: 'Fifth District',
+ NDO: 'Ninth District',
+ EDO: 'Eighth District',
+};
+const BASE_NAMES = {
+ 'RSA-5': 'Residential Single-Family Attached',
+ 'CMX-1': 'Neighborhood Commercial Mixed-Use',
+};
+
+// Map
+const FONT = 'Didot, Bodoni MT, serif';
+
+const map = new mapboxgl.Map({
+ container: 'map',
+ style: 'mapbox://styles/chloealimurong/cmuyof1fv00l301rwe8m15eoy',
+ zoom: 10.5,
+ center: [-75.165222, 39.952583],
+});
+map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'bottom-right');
+
+const EMPTY = { type: 'FeatureCollection', features: [] };
+
+// colors by zoninggroup; anything not listed falls back to gray
+const GROUP_COLORS = {
+ 'Residential/Multi-Family/Residential Mixed-Use': '#a6611a',
+ 'Commercial/Commercial Mixed-Use': '#dfc27d',
+ 'Industrial/Industrial Mixed-Use': '#80cdc1',
+ 'Special Purpose': '#018571',
+};
+const FALLBACK_COLOR = '#b0b0b0';
+
+const zoningColorExpr = [
+ 'match', ['get', 'zoninggroup'],
+ ...Object.entries(GROUP_COLORS).flat(),
+ FALLBACK_COLOR,
+];
+
+// Geocoding
+
+async function geocode(query) {
+ const url = new URL('https://api.mapbox.com/search/geocode/v6/forward');
+ url.search = new URLSearchParams({
+ q: query,
+ proximity: '-75.165222,39.952583',
+ bbox: '-75.28,39.86,-74.95,40.14',
+ types: 'address',
+ limit: 1,
+ access_token: mapboxgl.accessToken,
+ });
+ const res = await fetch(url);
+ if (!res.ok) throw new Error('Geocoding failed: ' + res.status);
+ const data = await res.json();
+ return data.features[0] || null;
+}
+
+// Spatial queries
+
+// ArcGIS FeatureServer (base districts + overlays)
+async function queryService(baseUrl, extraParams) {
+ const params = new URLSearchParams({
+ f: 'geojson',
+ where: '1=1',
+ inSR: 4326,
+ outSR: 4326,
+ spatialRel: 'esriSpatialRelIntersects',
+ outFields: '*',
+ ...extraParams,
+ });
+ const res = await fetch(`${baseUrl}?${params}`);
+ if (!res.ok) throw new Error(`Query failed: ${res.status}`);
+ const data = await res.json();
+ if (data.type !== 'FeatureCollection') {
+ throw new Error('Service did not return GeoJSON: ' + JSON.stringify(data).slice(0, 200));
+ }
+ return data;
+}
+
+const pointParams = (lng, lat) => ({
+ geometry: `${lng},${lat}`,
+ geometryType: 'esriGeometryPoint',
+});
+
+// districts inside the current map view (the colored overlay)
+async function loadZoningInView() {
+ if (!zoningVisible || map.getZoom() < MIN_ZONING_ZOOM) return;
+ const b = map.getBounds();
+ const data = await queryService(ZONING_URL, {
+ geometry: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','),
+ geometryType: 'esriGeometryEnvelope',
+ resultRecordCount: 2000,
+ });
+ map.getSource('zoning').setData(data);
+}
+
+// 1. base district containing the point
+async function zoningAtPoint(lng, lat) {
+ const data = await queryService(ZONING_URL, { ...pointParams(lng, lat), resultRecordCount: 1 });
+ return data.features[0] || null;
+}
+
+// 2. overlay codes at the point, e.g. ['NE']; null if the lookup failed
+async function overlaysAtPoint(lng, lat) {
+ try {
+ const data = await queryService(OVERLAY_URL, pointParams(lng, lat));
+ const codes = data.features
+ .map((f) => firstField(f.properties, OVERLAY_FIELDS))
+ .filter((s) => s && s.startsWith('/')) // skips '[N/A]' supplemental controls
+ .map(normalizeOverlay);
+ return [...new Set(codes)]; // /CTR etc. come back as many sub-areas
+ } catch (err) {
+ console.error('Overlay lookup failed', err);
+ return null;
+ }
+}
+
+// 3. historic register hits near the point (CARTO SQL); [] if none, null if a lookup failed
+async function historicAtPoint(lng, lat) {
+ try {
+ const results = await Promise.all(
+ HISTORIC_TABLES.map(async (t) => {
+ const sql = `SELECT ${t.columns} FROM ${t.table}
+ WHERE ST_DWithin(the_geom::geography,
+ ST_SetSRID(ST_MakePoint(${Number(lng)}, ${Number(lat)}), 4326)::geography,
+ ${t.distanceM ?? 0})
+ LIMIT 5`;
+ const res = await fetch(`${CARTO_SQL}?q=${encodeURIComponent(sql)}`);
+ if (!res.ok) throw new Error(`CARTO query failed (${t.table}): ${res.status}`);
+ const data = await res.json();
+ return data.rows.map(t.describe);
+ }),
+ );
+ return [...new Set(results.flat())];
+ } catch (err) {
+ console.error('Historic lookup failed', err);
+ return null;
+ }
+}
+
+// Helpers
+
+const firstField = (props, names) => {
+ for (const n of names) if (props?.[n]) return String(props[n]);
+ return null;
+};
+const normalizeOverlay = (s) => s.trim().replace(/^\//, '').toUpperCase();
+// 'NE' -> 'Northeast Overlay District (/NE)'; codes without a known name fall back to '/CTR overlay district'
+const overlayName = (code) =>
+ OVERLAY_NAMES[code] ? `${OVERLAY_NAMES[code]} Overlay District (/${code})` : `/${code} overlay district`;
+
+// 'RSA-5' -> 'Residential Single-Family Attached (RSA-5)'
+const baseName = (code) => (BASE_NAMES[code] ? `${BASE_NAMES[code]} (${code})` : code ?? 'unknown');
+
+const joinOr = (items) => items.join(' or ');
+
+function el(tag, text, props = {}) {
+ const e = document.createElement(tag);
+ if (text != null) e.textContent = text;
+ Object.assign(e, props);
+ return e;
+}
+
+// Step 1: spatial check (automatic, follows the top of the L&I flowchart)
+
+// Runs the four map-based checks in flowchart order and stops at the first failure.
+// Each check has a status: 'pass' | 'fail' | 'none' | 'skip' | 'unknown'.
+function spatialCheck(state) {
+ const { base, overlays, historic, historicHits } = state;
+ const hit = (list) => overlays?.filter((o) => list.includes(o)) ?? [];
+ const names = (list) => hit(list).map(overlayName).join(', ');
+
+ const checks = [
+ {
+ id: 'fne',
+ label: `Not in the ${joinOr(BLOCK_OVERLAYS_ALWAYS.map(overlayName))}`,
+ },
+ { id: 'historic', label: 'The structure is historic (14-604(11)(d)(.1))' },
+ {
+ id: 'other-overlay',
+ label: `Not in the ${joinOr(BLOCK_OVERLAYS_OTHER.map(overlayName))}`,
+ },
+ {
+ id: 'base',
+ label: `Base zoning district is ${joinOr(ALLOWED_BASE.map(baseName))}`,
+ },
+ ].map((c) => ({ ...c, status: 'skip', detail: 'not reached' }));
+ const [fne, hist, other, baseCheck] = checks;
+
+ if (overlays === null) {
+ fne.status = 'unknown';
+ fne.detail = 'Overlay districts could not be loaded';
+ return {
+ checks,
+ outcome: 'unknown',
+ reason: 'Overlay districts could not be loaded, so this cannot be checked automatically.',
+ };
+ }
+
+ // 1. /FNE or /NE: no exception, not even for historic structures
+ if (hit(BLOCK_OVERLAYS_ALWAYS).length) {
+ fne.status = 'fail';
+ fne.detail = `The property is in the ${names(BLOCK_OVERLAYS_ALWAYS)}`;
+ return {
+ checks,
+ outcome: 'blocked',
+ canBeHistoric: false,
+ reason: `The property is in the ${names(BLOCK_OVERLAYS_ALWAYS)}. ADUs are not allowed there, even for historic structures.`,
+ };
+ }
+ fne.status = 'pass';
+ fne.detail = '';
+
+ // 2. historic structure skips the remaining spatial checks
+ if (historicHits === null) hist.detail = 'Register lookup unavailable';
+ else if (historicHits.length) hist.detail = `Register: ${historicHits.join('; ')}`;
+ else hist.detail = 'No register match found';
+
+ if (historic) {
+ hist.status = 'pass';
+ other.detail = 'skipped (historic structure)';
+ baseCheck.detail = 'skipped (historic structure)';
+ return {
+ checks,
+ outcome: 'pass',
+ reason: 'The structure is historic, so the remaining overlay and zoning checks are skipped.',
+ };
+ }
+ hist.status = 'none';
+
+ // 3. /FDO /VDO /NDO /EDO
+ if (hit(BLOCK_OVERLAYS_OTHER).length) {
+ other.status = 'fail';
+ other.detail = `The property is in the ${names(BLOCK_OVERLAYS_OTHER)}`;
+ return {
+ checks,
+ outcome: 'blocked',
+ canBeHistoric: true,
+ reason: `The property is in the ${names(BLOCK_OVERLAYS_OTHER)}. ADUs are not allowed there unless the structure is historic.`,
+ };
+ }
+ other.status = 'pass';
+ other.detail = '';
+
+ // 4. base district
+ if (!ALLOWED_BASE.includes(base)) {
+ baseCheck.status = 'fail';
+ baseCheck.detail = `The base zoning district is ${baseName(base)}`;
+ return {
+ checks,
+ outcome: 'blocked',
+ canBeHistoric: true,
+ reason: `The base zoning district is ${baseName(base)}. An ADU needs ${joinOr(ALLOWED_BASE.map(baseName))}.`,
+ };
+ }
+ baseCheck.status = 'pass';
+ baseCheck.detail = baseName(base);
+
+ return {
+ checks,
+ outcome: 'pass',
+ reason: 'The overlay districts and base zoning district allow an ADU. Next, tell us about the property.',
+ };
+}
+
+// Step 2: user input (statements the user selects)
+
+const PROPERTY_ITEMS = [
+ {
+ id: 'structure',
+ text: 'The principal structure is semi-detached or detached',
+ blocker: 'Principal structure must be detached or semi-detached',
+ },
+ {
+ id: 'lot',
+ text: 'The lot is at least 1,600 sq ft AND no more than one dwelling unit is otherwise permitted',
+ hint: 'Check your deed or a survey for lot area, and the use table in 14-602 for permitted units.',
+ blocker: 'Lot must be at least 1,600 sq ft and allow no more than one dwelling unit',
+ },
+];
+
+const REQUIREMENT_ITEMS = [
+ { id: 'owner-lives', group: 'Owner occupied', text: 'The owner lives in the principal unit or the ADU' },
+ {
+ id: 'owner-deed',
+ group: 'Owner occupied',
+ text: 'Owner will record an affidavit and deed restriction before final occupancy',
+ },
+ { id: 'one-adu', group: 'Maximum of one ADU', text: 'No more than one ADU on the lot' },
+ {
+ id: 'floor-area',
+ group: 'Floor area',
+ text: 'The ADU is 800 sq ft or less',
+ historicText: 'The ADU may exceed 800 sq ft (historic structure)',
+ },
+ {
+ id: 'entrances',
+ group: 'Number of entrances',
+ text: 'Only one entrance on the street-facing front façade, unless the building already had an additional one before the ADU',
+ },
+ {
+ id: 'location',
+ group: 'Location',
+ text: 'The ADU is inside the principal building or an existing detached accessory building (e.g. a detached garage)',
+ },
+];
+
+// the 800 sq ft limit does not apply to historic structures
+const itemText = (item, state) => (item.historicText && state.historic ? item.historicText : item.text);
+
+// Step 3: result
+
+function evaluate(state) {
+ const spatial = spatialCheck(state);
+ const hint = 'Unless the ADU is within a historic structure. You can change this in step 1.';
+
+ if (spatial.outcome === 'unknown') {
+ return { status: 'unknown', headline: 'Check incomplete', summary: spatial.reason };
+ }
+ if (spatial.outcome === 'blocked') {
+ return {
+ status: 'no',
+ headline: 'ADU not permitted',
+ summary: 'You cannot build an ADU on this property.',
+ reasons: [spatial.reason],
+ hint: spatial.canBeHistoric ? hint : '',
+ };
+ }
+
+ // structure type and lot size are skipped for historic structures
+ const reasons = state.historic
+ ? []
+ : PROPERTY_ITEMS.filter((i) => !state.picked[i.id]).map((i) => `${i.blocker} (not selected)`);
+ if (reasons.length) {
+ return {
+ status: 'no',
+ headline: 'ADU not permitted',
+ summary: 'You cannot build an ADU on this property as answered.',
+ reasons,
+ hint,
+ };
+ }
+
+ const unmet = REQUIREMENT_ITEMS.filter((i) => !state.picked[i.id]).map((i) => itemText(i, state));
+ if (unmet.length) {
+ return {
+ status: 'pending',
+ headline: 'ADU permitted, pending additional requirements',
+ summary: 'You can build an ADU once these requirements are met.',
+ unmet,
+ };
+ }
+
+ return {
+ status: 'yes',
+ headline: 'ADU permitted',
+ summary: 'You can build an ADU on this property.',
+ note: 'All checks passed and all additional code requirements are confirmed. L&I verifies these during review.',
+ };
+}
+
+// Popup report
+
+const COLORS = { yes: '#2b8a3e', no: '#c92a2a', unknown: '#868e96' };
+
+// 'permitted, pending additional requirements': yellow background, dark text for contrast
+const PENDING = { bg: '#ffd43b', text: '#212529' };
+
+const STAGE_NAMES = ['Spatial check', 'Your answers', 'Result'];
+
+const STATUS_STYLE = {
+ pass: { icon: '✓', bg: '#d3f9d8', color: COLORS.yes },
+ fail: { icon: '✕', bg: '#ffe3e3', color: COLORS.no },
+ none: { icon: '–', bg: '#f1f3f5', color: COLORS.unknown },
+ skip: { icon: '–', bg: '#f1f3f5', color: '#adb5bd' },
+ unknown: { icon: '?', bg: '#f1f3f5', color: COLORS.unknown },
+};
+
+let popup = null;
+let marker = null;
+
+function button(text, onClick, { primary = false, disabled = false, small = false } = {}) {
+ const b = el('button', text, { type: 'button', disabled });
+ b.style.cssText =
+ `padding:${small ? '2px 10px' : '7px 16px'};border-radius:999px;font:inherit;` +
+ `font-size:${small ? '.8em' : '.92em'};font-weight:600;` +
+ `cursor:${disabled ? 'not-allowed' : 'pointer'};opacity:${disabled ? 0.5 : 1};` +
+ `border:1.5px solid ${primary ? '#1971c2' : '#ced4da'};` +
+ `background:${primary ? '#1971c2' : '#fff'};color:${primary ? '#fff' : '#212529'}`;
+ if (onClick && !disabled) b.addEventListener('click', onClick);
+ return b;
+}
+
+function actionsRow(...buttons) {
+ const row = el('div');
+ row.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;margin-top:12px';
+ row.append(...buttons);
+ return row;
+}
+
+// small tag shown when the answer differs from what the data suggested
+function manualTag() {
+ const tag = el('span', 'manually set', {
+ title: 'This answer differs from what the register data suggests',
+ });
+ tag.style.cssText =
+ 'font-size:.75em;font-weight:600;padding:1px 7px;border-radius:.75em;' +
+ 'background:#fff4e6;color:#d9480f;border:1px solid #ffa94d';
+ return tag;
+}
+
+// the three-step progress bar at the top of the popup
+function stepper(state) {
+ const skipAnswers = spatialCheck(state).outcome !== 'pass';
+ const wrap = el('div');
+ wrap.style.cssText = 'display:flex;gap:6px;margin:8px 0 12px';
+
+ STAGE_NAMES.forEach((name, i) => {
+ const n = i + 1;
+ const current = n === state.stage;
+ const skipped = n === 2 && skipAnswers && state.stage === 3;
+ const done = n < state.stage && !skipped;
+
+ let prefix = `${n}`;
+ if (done) prefix = '✓';
+ if (skipped) prefix = '–';
+
+ let bg = '#f1f3f5';
+ let color = '#868e96';
+ if (current) {
+ bg = '#212529';
+ color = '#fff';
+ } else if (done) {
+ bg = '#d3f9d8';
+ color = COLORS.yes;
+ }
+
+ const pill = el('div', `${prefix} ${name}`);
+ pill.style.cssText =
+ `flex:1;text-align:center;padding:5px 4px;border-radius:999px;font-size:.8em;font-weight:600;` +
+ `background:${bg};color:${color}`;
+ wrap.append(pill);
+ });
+ return wrap;
+}
+
+function checkRow(check, state, rerender) {
+ const s = STATUS_STYLE[check.status];
+ const row = el('div');
+ row.style.cssText =
+ `display:flex;gap:10px;align-items:flex-start;margin:8px 0;opacity:${check.status === 'skip' ? 0.5 : 1}`;
+
+ const icon = el('div', s.icon);
+ icon.style.cssText =
+ 'flex:none;width:1.6em;height:1.6em;border-radius:50%;display:flex;align-items:center;' +
+ `justify-content:center;font-weight:700;font-size:.9em;background:${s.bg};color:${s.color}`;
+
+ const text = el('div');
+ const label = el('div', check.label);
+ label.style.cssText = 'font-weight:600';
+ text.append(label);
+
+ if (check.detail) {
+ const detail = el('div', check.detail);
+ detail.style.cssText = 'font-size:.85em;color:#666;margin-top:2px';
+ text.append(detail);
+ }
+
+ // the historic answer can be changed by hand if the register data is wrong or out of date
+ if (check.id === 'historic' && check.status !== 'skip' && check.status !== 'unknown') {
+ const controls = el('div');
+ controls.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:4px;flex-wrap:wrap';
+ controls.append(
+ button(
+ state.historic ? 'Mark as not historic' : 'Mark as historic',
+ () => {
+ state.historic = !state.historic;
+ rerender();
+ },
+ { small: true },
+ ),
+ );
+ if (state.historic !== state.suggestedHistoric) controls.append(manualTag());
+ text.append(controls);
+ }
+
+ row.append(icon, text);
+ return row;
+}
+
+function renderSpatial(box, state, rerender, go) {
+ const spatial = spatialCheck(state);
+
+ const facts = el('p');
+ facts.style.cssText = 'font-size:1em;margin:4px 0 8px';
+ facts.append(
+ el('strong', 'Base zoning district: '), baseName(state.base), el('br'),
+ el('strong', 'Overlay districts: '),
+ state.overlays === null
+ ? 'unavailable'
+ : state.overlays.length ? state.overlays.map(overlayName).join(', ') : 'none',
+ );
+ box.append(facts);
+
+ for (const check of spatial.checks) box.append(checkRow(check, state, rerender));
+
+ const titles = {
+ pass: 'Passed the spatial checks',
+ blocked: 'Stopped by a spatial check',
+ unknown: 'Spatial check incomplete',
+ };
+ const tone = { pass: COLORS.yes, blocked: COLORS.no, unknown: COLORS.unknown };
+ const banner = el('div');
+ banner.style.cssText =
+ `margin:12px 0 0;padding:8px 10px;border-radius:6px;font-size:.92em;color:#fff;background:${tone[spatial.outcome]}`;
+ banner.append(el('strong', titles[spatial.outcome]), el('br'), spatial.reason);
+ box.append(banner);
+
+ if (spatial.outcome === 'pass') {
+ box.append(actionsRow(button('Next: your answers', () => go(2), { primary: true })));
+ } else if (spatial.outcome === 'blocked') {
+ box.append(actionsRow(button('See result', () => go(3), { primary: true })));
+ } else {
+ box.append(actionsRow(button('Next', null, { disabled: true })));
+ }
+}
+
+// a statement the user taps; it turns green when selected
+function pickRow(item, text, state, rerender) {
+ const on = Boolean(state.picked[item.id]);
+ const row = el('button', null, { type: 'button' });
+ row.setAttribute('role', 'checkbox');
+ row.setAttribute('aria-checked', String(on));
+ row.style.cssText =
+ 'display:block;width:100%;text-align:left;margin:5px 0;padding:8px 10px;border-radius:8px;' +
+ `cursor:pointer;font:inherit;font-size:.92em;font-weight:${on ? 600 : 400};` +
+ `border:1.5px solid ${on ? COLORS.yes : '#ced4da'};background:${on ? '#d3f9d8' : '#fff'};` +
+ `color:${on ? '#1b5e20' : '#212529'}`;
+
+ row.append(el('span', `${on ? '✓ ' : ''}${text}`));
+ if (item.hint) {
+ const hint = el('span', item.hint);
+ hint.style.cssText = 'display:block;font-weight:400;font-size:.85em;color:#666;margin-top:2px';
+ row.append(hint);
+ }
+
+ row.addEventListener('click', () => {
+ state.picked[item.id] = !on;
+ rerender();
+ });
+ return row;
+}
+
+function sectionHeading(text) {
+ const h = el('div', text);
+ h.style.cssText = 'font-weight:700;margin:12px 0 2px';
+ return h;
+}
+
+function groupHeading(text) {
+ const h = el('div', text);
+ h.style.cssText = 'font-size:.8em;font-weight:600;color:#495057;margin:8px 0 0;text-transform:uppercase';
+ return h;
+}
+
+function renderAnswers(box, state, rerender, go) {
+ const intro = el('p', 'Select each statement that is true for your property. Selected statements turn green.');
+ intro.style.cssText = 'font-size:.92em;color:#495057;margin:4px 0 0';
+ box.append(intro);
+
+ if (state.historic) {
+ const skip = el('p', 'Structure type and lot size are skipped for historic structures.');
+ skip.style.cssText = 'font-size:.85em;color:#666;margin:8px 0 0';
+ box.append(skip);
+ } else {
+ box.append(sectionHeading('About the property'));
+ for (const item of PROPERTY_ITEMS) box.append(pickRow(item, item.text, state, rerender));
+ }
+
+ box.append(sectionHeading('Additional code requirements'));
+ let lastGroup = null;
+ for (const item of REQUIREMENT_ITEMS) {
+ if (item.group !== lastGroup) {
+ box.append(groupHeading(item.group));
+ lastGroup = item.group;
+ }
+ box.append(pickRow(item, itemText(item, state), state, rerender));
+ }
+
+ box.append(
+ actionsRow(
+ button('Back', () => go(1)),
+ button('See result', () => go(3), { primary: true }),
+ ),
+ );
+}
+
+function bulletList(items) {
+ const ul = el('ul');
+ ul.style.cssText = 'margin:6px 0;padding-left:1.2em;font-size:.92em';
+ for (const text of items) ul.append(el('li', text));
+ return ul;
+}
+
+function renderResult(box, state, rerender, go) {
+ const r = evaluate(state);
+ const pending = r.status === 'pending';
+
+ const banner = el('div');
+ banner.style.cssText =
+ `padding:10px 12px;border-radius:6px;margin:6px 0;background:${pending ? PENDING.bg : COLORS[r.status]};` +
+ `color:${pending ? PENDING.text : '#fff'}`;
+ const headline = el('div', r.headline);
+ headline.style.cssText = 'font-size:1.15em;font-weight:700';
+ const summary = el('div', r.summary);
+ summary.style.cssText = 'font-size:.92em;margin-top:2px';
+ banner.append(headline, summary);
+ box.append(banner);
+
+ if (r.reasons) {
+ box.append(el('strong', 'Why not:'), bulletList(r.reasons));
+ }
+ if (r.unmet) {
+ box.append(el('strong', 'Still to meet:'), bulletList(r.unmet));
+ }
+ if (r.hint) {
+ const hint = el('p', r.hint);
+ hint.style.cssText = 'font-size:.92em;margin:6px 0';
+ box.append(hint);
+ }
+ if (r.note) {
+ const note = el('p', r.note);
+ note.style.cssText = 'font-size:.92em;margin:6px 0';
+ box.append(note);
+ }
+
+ const footnote = el('p', 'Historic status is confirmed by the Historical Commission during L&I review.');
+ footnote.style.cssText = 'font-size:.85em;color:#666;margin:10px 0 0';
+ box.append(footnote);
+
+ const backTo = spatialCheck(state).outcome === 'pass' ? 2 : 1;
+ box.append(actionsRow(button('Back', () => go(backTo))));
+}
+
+// true when there is room to open the report beside the pin
+const hasSideRoom = () => map.getContainer().clientWidth >= 700;
+
+function showReport({ place, lngLat, zoning, overlays, historicHits }) {
+ if (popup) popup.remove();
+ if (marker) marker.remove();
+
+ const root = el('div');
+ root.style.cssText =
+ `font-family:${FONT};font-size:17px;width:min(560px, 85vw);max-height:68vh;overflow-y:auto;padding-right:4px`;
+
+ if (!zoning) {
+ root.append(el('p', place ? 'No zoning district found at that address.' : 'Address not found in Philadelphia.'));
+ } else {
+ const suggestedHistoric = Boolean(historicHits?.length);
+ const state = {
+ base: firstField(zoning.properties, BASE_FIELDS),
+ overlays,
+ historicHits,
+ suggestedHistoric, // what the register data says
+ historic: suggestedHistoric, // what the user has chosen (starts at the register answer)
+ picked: {}, // statement id -> true when the user selects it
+ stage: 1,
+ };
+
+ const progress = el('div');
+ const body = el('div');
+
+ const rerender = () => {
+ progress.replaceChildren(stepper(state));
+ body.replaceChildren();
+ if (state.stage === 1) renderSpatial(body, state, rerender, go);
+ else if (state.stage === 2) renderAnswers(body, state, rerender, go);
+ else renderResult(body, state, rerender, go);
+ };
+ const go = (stage) => {
+ state.stage = stage;
+ rerender();
+ root.scrollTop = 0;
+ };
+
+ root.append(el('h3', place?.properties?.full_address ?? 'Zoning report'), progress, body);
+ rerender();
+ }
+
+ popup = new mapboxgl.Popup({
+ closeOnClick: false,
+ maxWidth: 'none',
+ anchor: hasSideRoom() ? 'left' : undefined, // 'left' = box opens to the right of the pin
+ }).setDOMContent(root);
+
+ if (place) {
+ // pin on the address, report box opens beside it
+ marker = new mapboxgl.Marker({ color: '#f1b6da' })
+ .setLngLat(lngLat)
+ .setPopup(popup)
+ .addTo(map);
+ marker.togglePopup();
+ } else {
+ // address not found: no pin, just show the message at the map center
+ popup.setLngLat(lngLat).addTo(map);
+ }
+}
+
+// Address search
+
+async function searchAddress(query) {
+ const place = await geocode(query);
+ if (!place) {
+ return showReport({ place: null, lngLat: map.getCenter(), zoning: null });
+ }
+
+ const [lng, lat] = place.geometry.coordinates;
+ map.flyTo({
+ center: [lng, lat],
+ zoom: 18,
+ padding: {
+ right: hasSideRoom() ? Math.min(620, Math.round(map.getContainer().clientWidth * 0.55)) : 0,
+ },
+ });
+
+ const [zoning, overlays, historicHits] = await Promise.all([
+ zoningAtPoint(lng, lat),
+ overlaysAtPoint(lng, lat),
+ historicAtPoint(lng, lat),
+ ]);
+ map.getSource('selected-zoning').setData(zoning || EMPTY);
+ showReport({ place, lngLat: [lng, lat], zoning, overlays, historicHits });
+}
+
+const input = document.querySelector('[name=address-search]');
+input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && input.value.trim()) {
+ searchAddress(input.value.trim()).catch(console.error);
+ }
+});
+
+// Map setup
+
+map.on('load', () => {
+ // hide road-number shields; they log a Mapbox expression warning on some tiles
+ map.getStyle().layers.forEach((layer) => {
+ if (layer.type === 'symbol' && /shield/i.test(layer.id)) {
+ map.setLayoutProperty(layer.id, 'visibility', 'none');
+ }
+ });
+
+ // all districts in view
+ map.addSource('zoning', { type: 'geojson', data: EMPTY });
+ map.addLayer({
+ id: 'zoning-fill',
+ type: 'fill',
+ source: 'zoning',
+ paint: { 'fill-color': zoningColorExpr, 'fill-opacity': 0.6 },
+ });
+ map.addLayer({
+ id: 'zoning-outline',
+ type: 'line',
+ source: 'zoning',
+ paint: { 'line-color': '#999', 'line-width': 0.5 },
+ });
+
+ // the district found by address search
+ map.addSource('selected-zoning', { type: 'geojson', data: EMPTY });
+ map.addLayer({
+ id: 'selected-zoning-outline',
+ type: 'line',
+ source: 'selected-zoning',
+ paint: { 'line-color': '#f1b6da', 'line-width': 3 },
+ });
+
+ map.on('moveend', () => loadZoningInView().catch(console.error));
+});
+
+// Legend (with the zoning layer toggle)
+
+const legend = document.createElement('div');
+legend.style.cssText =
+ 'position:absolute;bottom:30px;right:50px;background:#fff;padding:.7em .9em;border-radius:6px;' +
+ `font:14px ${FONT};box-shadow:0 1px 4px rgb(0 0 0 / 30%)`;
+
+function legendRow(color, label) {
+ const row = document.createElement('div');
+ row.style.cssText = 'display:flex;align-items:center;gap:.6em;margin:.25em 0';
+ const swatch = document.createElement('span');
+ swatch.style.cssText = `flex:none;width:1em;height:1em;border-radius:2px;background:${color}`;
+ row.append(swatch, label);
+ return row;
+}
+
+// checkbox that shows or hides the zoning layer
+const toggleLabel = document.createElement('label');
+toggleLabel.style.cssText = 'display:flex;align-items:center;gap:.6em;font-weight:600;cursor:pointer';
+const toggle = document.createElement('input');
+toggle.type = 'checkbox';
+toggle.checked = true;
+toggleLabel.append(toggle, 'Zoning districts');
+
+const toggleHint = document.createElement('div');
+toggleHint.textContent = 'Zoom in to see districts';
+toggleHint.style.cssText = 'font-size:.85em;color:#666;margin:.15em 0 .3em';
+
+// one row per zoning group
+const zoningRows = document.createElement('div');
+for (const [name, color] of Object.entries(GROUP_COLORS)) {
+ zoningRows.append(legendRow(color, name.split('/')[0])); // 'Residential', 'Commercial', ...
+}
+
+function setZoningVisible(visible) {
+ zoningVisible = visible;
+ zoningRows.style.display = visible ? '' : 'none';
+ if (!map.getLayer('zoning-fill')) return; // map has not finished loading yet
+ for (const id of ['zoning-fill', 'zoning-outline']) {
+ map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
+ }
+ if (visible) loadZoningInView().catch(console.error); // districts were not fetched while hidden
+}
+toggle.addEventListener('change', () => setZoningVisible(toggle.checked));
+
+// the searched district outline stays visible whether the zoning layer is on or off
+legend.append(toggleLabel, toggleHint, zoningRows, legendRow('#f1b6da', 'Base Zoning District Box'));
+
+map.getContainer().append(legend);
+
+window.map = map;
\ No newline at end of file
diff --git a/mockup/css/styles.css b/mockup/css/styles.css
deleted file mode 100644
index 829ab01c..00000000
--- a/mockup/css/styles.css
+++ /dev/null
@@ -1,137 +0,0 @@
-html {
- font-size: 24px;
- font-family: sans-serif;
-}
-
-body {
- padding: 0;
- margin: 0;
-}
-
-.map-section {
- position: relative;
- width: 100%;
- height: 50vh;
- background-color: silver;
-}
-
-.map-search-controls {
- display: flex;
- box-sizing: border-box;
- margin: 0;
- border: none;
- padding: 0.5rem;
- width: 100%;
- gap: 0.5rem;
- justify-content: space-between;
- position: relative;
- z-index: 1;
-}
-
-.map-search-controls input {
- box-sizing: border-box;
- border: 1px solid gray;
- border-radius: 4px;
- font-size: 0.9rem;
- font-family: monospace;
-}
-
-[name="address-search"] {
- background-image: url("../img/location.svg");
- background-size: 1em;
- background-repeat: no-repeat;
- background-position: 98% center;
- padding-right: 1.2em;
- width: 60%
-}
-
-[name="time-search"] {
- width: 40%;
-}
-
-.map {
- position: absolute;
- top: 0;
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.bike-search-controls {
- border: 0;
- padding: 0.5rem;
- display: flex;
- justify-content: space-between
-}
-
-.station-list {
- list-style-type: none;
- padding: 0;
- margin: 0;
-}
-
-.station-list .station {
- display: grid;
- grid-template-areas: "name name"
- "blank distance"
- "bikes-info docks-info"
- "drop-off-info pick-up-info"
- "details details";
- grid-template-columns: 1fr 1fr;
-
- padding: 0.5rem;
- border-bottom: 1px solid black;
-}
-
-.station-list .station:first-child {
- border-top: 1px solid black;
-}
-
-.station .name {
- grid-area: name;
- font-weight: bold;
-}
-
-.station .distance {
- grid-area: distance;
- justify-self: end;
-
- font-size: 0.8em;
- font-style: italic;
- padding-bottom: 0.5rem
-}
-
-.station .available-bikes {
- grid-area: bikes-info;
-}
-
-.station .available-docks {
- grid-area: docks-info;
- text-align: right;
-}
-
-.station .next-drop-off-est {
- grid-area: drop-off-info;
-}
-
-.station .next-pick-up-est {
- grid-area: pick-up-info;
- text-align: right;
-}
-
-.station .details {
- grid-area: details;
- justify-self: center;
-}
-
-.station .details .chart {
- box-sizing: border-box;
- margin-top: 0.5rem;
- padding: 0.5rem;
- width: 100%;
- height: 4rem;
- background-color: silver;
-
-}
\ No newline at end of file
diff --git a/mockup/index.html b/mockup/index.html
deleted file mode 100644
index bef07e29..00000000
--- a/mockup/index.html
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
- Bikeshare Station Availability Dashboard
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 90261a36..70337bc0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,8 +12,8 @@
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@stylistic/stylelint-plugin": "^5.3.0",
- "eslint": "^10.9.1",
- "globals": "^17.11.0",
+ "eslint": "^10.13.0",
+ "globals": "^17.13.0",
"stylelint": "^17.14.1",
"stylelint-config-standard": "^40.0.0"
}
@@ -891,9 +891,9 @@
}
},
"node_modules/eslint": {
- "version": "10.11.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
- "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
+ "version": "10.13.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
+ "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -1291,9 +1291,9 @@
}
},
"node_modules/globals": {
- "version": "17.12.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
- "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
+ "version": "17.13.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
+ "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index d99db1cd..e8736ba0 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,8 @@
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@stylistic/stylelint-plugin": "^5.3.0",
- "eslint": "^10.9.1",
- "globals": "^17.11.0",
+ "eslint": "^10.13.0",
+ "globals": "^17.13.0",
"stylelint": "^17.14.1",
"stylelint-config-standard": "^40.0.0"
}