@@ -181,6 +181,8 @@ interface Control {
181181 info ?: string ;
182182 /** Hidden unless the named sibling control's current value is one of `values`. */
183183 visibleWhen ?: { key : StateKey ; values : readonly string [ ] } ;
184+ /** Row-width hint relative to its siblings — short/fixed-shape values (a price, a 3-letter currency code) don't need as much room as a name or URL. Omit for the default (m). */
185+ size ?: "s" | "m" | "l" ;
184186}
185187
186188interface FieldSpec {
@@ -461,17 +463,25 @@ const FIELD_SPECS: Record<string, FieldSpec> = {
461463 note : "Only for events accepting talk/workshop submissions." ,
462464 info : "The one OTE field with no equivalent in ICS, RSS or plain schema.org — it exists because \"which conferences are still accepting proposals\" is a question only the organizer can answer today." ,
463465 controls : [
464- { key : "cfpUrl" , label : "URL" , kind : "url" , info : "Where speakers submit a proposal." } ,
466+ {
467+ key : "cfpUrl" ,
468+ label : "URL" ,
469+ kind : "url" ,
470+ size : "l" ,
471+ info : "Where speakers submit a proposal." ,
472+ } ,
465473 {
466474 key : "cfpOpensAt" ,
467475 label : "Opens at" ,
468476 kind : "instant" ,
477+ size : "l" ,
469478 info : "When submissions start being accepted." ,
470479 } ,
471480 {
472481 key : "cfpClosesAt" ,
473482 label : "Closes at" ,
474483 kind : "instant" ,
484+ size : "l" ,
475485 info : "Submission deadline — after this, cfpUrl should stop accepting new ones." ,
476486 } ,
477487 {
@@ -529,24 +539,28 @@ const FIELD_SPECS: Record<string, FieldSpec> = {
529539 key : "sourceName" ,
530540 label : "Name" ,
531541 kind : "text" ,
542+ size : "l" ,
532543 info : "Where this data was imported from — a platform name (\"Meetup\") or organization." ,
533544 } ,
534545 {
535546 key : "sourceUrl" ,
536547 label : "URL" ,
537548 kind : "url" ,
549+ size : "l" ,
538550 info : "The exact page or feed this event's data was read from." ,
539551 } ,
540552 {
541553 key : "sourceLicense" ,
542554 label : "License" ,
543555 kind : "text" ,
556+ size : "s" ,
544557 info : "The license the SOURCE data was published under, if stated — not this file's own license (see License above)." ,
545558 } ,
546559 {
547560 key : "sourceRetrievedAt" ,
548561 label : "Retrieved at" ,
549562 kind : "instant" ,
563+ size : "l" ,
550564 info : "When this data was fetched — set automatically by the import flow; edit only if importing by hand." ,
551565 } ,
552566 ] ,
@@ -905,6 +919,8 @@ interface RepeaterItemField {
905919 info ?: string ;
906920 /** Hidden unless the named sibling field's current value (within the same row) is one of `values`. */
907921 visibleWhen ?: { key : string ; values : readonly string [ ] } ;
922+ /** Row-width hint relative to its siblings — see Control.size. Omit for the default (m). */
923+ size ?: "s" | "m" | "l" ;
908924}
909925
910926interface RepeaterSpec {
@@ -927,13 +943,15 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
927943 key : "name" ,
928944 label : "Name" ,
929945 kind : "text" ,
946+ size : "l" ,
930947 info : "As attendees would recognize it — the group or person's usual name." ,
931948 } ,
932949 {
933950 key : "url" ,
934951 label : "URL" ,
935952 kind : "url" ,
936953 placeholder : "https://…" ,
954+ size : "l" ,
937955 info : "Website or profile for this organizer. Leave empty if they don't have one." ,
938956 } ,
939957 {
@@ -947,6 +965,7 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
947965 key : "type" ,
948966 label : "Type" ,
949967 kind : "select" ,
968+ size : "s" ,
950969 options : [ "" , "organization" , "person" ] ,
951970 info : "Organization or individual person — lets consumers pick the right icon or label." ,
952971 } ,
@@ -972,52 +991,60 @@ const REPEATER_SPECS: Record<RepeaterKey, RepeaterSpec> = {
972991 label : "Name" ,
973992 kind : "text" ,
974993 placeholder : "General admission" ,
994+ size : "l" ,
975995 info : "What this tier is called — \"General admission\", \"Early bird\", \"Student\"…" ,
976996 } ,
977997 {
978998 key : "price" ,
979999 label : "Price" ,
9801000 kind : "number" ,
9811001 placeholder : "0" ,
1002+ size : "s" ,
9821003 info : "0 means free. Leave empty only when the price itself is unknown — that's not the same as free." ,
9831004 } ,
9841005 {
9851006 key : "currency" ,
9861007 label : "Currency" ,
9871008 kind : "text" ,
9881009 placeholder : "EUR" ,
1010+ size : "s" ,
9891011 info : "ISO 4217 code (EUR, USD…) — expected alongside a non-zero price." ,
9901012 } ,
9911013 {
9921014 key : "url" ,
9931015 label : "URL" ,
9941016 kind : "url" ,
1017+ size : "l" ,
9951018 info : "Where to buy or register for this specific tier." ,
9961019 } ,
9971020 {
9981021 key : "availability" ,
9991022 label : "Availability" ,
10001023 kind : "select" ,
1024+ size : "s" ,
10011025 options : [ "" , "in-stock" , "sold-out" ] ,
10021026 info : "sold-out reveals a Waitlist URL below. Leave empty when unknown." ,
10031027 } ,
10041028 {
10051029 key : "waitlistUrl" ,
10061030 label : "Waitlist URL" ,
10071031 kind : "url" ,
1032+ size : "l" ,
10081033 visibleWhen : { key : "availability" , values : [ "sold-out" ] } ,
10091034 info : "Where interested people can join the waitlist once this tier sells out." ,
10101035 } ,
10111036 {
10121037 key : "opensAt" ,
10131038 label : "Opens at" ,
10141039 kind : "instant" ,
1040+ size : "l" ,
10151041 info : "When this tier becomes available for purchase — not the event's own start." ,
10161042 } ,
10171043 {
10181044 key : "closesAt" ,
10191045 label : "Closes at" ,
10201046 kind : "instant" ,
1047+ size : "l" ,
10211048 info : "When this tier stops being available (sale ends) — not the event's own end." ,
10221049 } ,
10231050 ] ,
@@ -1166,6 +1193,7 @@ function renderRepeaterItemControl(
11661193 if ( describedBy ) rendered . input . setAttribute ( "aria-describedby" , describedBy ) ;
11671194 if ( ! field . label ) return rendered ;
11681195 const wrap = document . createElement ( "div" ) ;
1196+ if ( field . size ) wrap . classList . add ( `size-${ field . size } ` ) ;
11691197 const label = document . createElement ( "label" ) ;
11701198 label . htmlFor = rendered . input . id ;
11711199 label . textContent = t ( `${ i18nKey } .label` , field . label ) ;
@@ -1197,6 +1225,7 @@ function renderRepeaterItemControl(
11971225
11981226 if ( ! field . label ) return { element : input , input } ;
11991227 const wrap = document . createElement ( "div" ) ;
1228+ if ( field . size ) wrap . classList . add ( `size-${ field . size } ` ) ;
12001229 const label = document . createElement ( "label" ) ;
12011230 label . htmlFor = input . id ;
12021231 label . textContent = t ( `${ i18nKey } .label` , field . label ) ;
@@ -1816,6 +1845,7 @@ function renderControl(
18161845 rendered . input . dataset . key = control . key ;
18171846 if ( ! control . label ) return rendered ;
18181847 const wrap = document . createElement ( "div" ) ;
1848+ if ( control . size ) wrap . classList . add ( `size-${ control . size } ` ) ;
18191849 const label = document . createElement ( "label" ) ;
18201850 label . htmlFor = rendered . input . id ;
18211851 label . textContent = t ( `control.${ control . key } .label` , control . label ) ;
@@ -1862,6 +1892,7 @@ function renderControl(
18621892
18631893 if ( ! control . label ) return { element : input , input } ;
18641894 const wrap = document . createElement ( "div" ) ;
1895+ if ( control . size ) wrap . classList . add ( `size-${ control . size } ` ) ;
18651896 const label = document . createElement ( "label" ) ;
18661897 label . htmlFor = input . id ;
18671898 label . textContent = t ( `control.${ control . key } .label` , control . label ) ;
@@ -1932,23 +1963,7 @@ function renderField(
19321963 errorId = appendError ( outer ) ;
19331964 // The map is the primary way to set a position — typing raw decimal
19341965 // degrees by hand is the rare case, so it isn't invited by default.
1935- // readOnly (not disabled) keeps the values focusable/copyable and
1936- // still shows whatever the map writes via main.ts's existing sync.
1937- for ( const c of controls ) {
1938- if ( c . input instanceof HTMLInputElement ) c . input . readOnly = true ;
1939- }
1940- const manualToggle = document . createElement ( "button" ) ;
1941- manualToggle . type = "button" ;
1942- manualToggle . className = "link-button" ;
1943- manualToggle . textContent = t ( "ui.enterCoordinatesManually" , "Enter coordinates manually" ) ;
1944- manualToggle . addEventListener ( "click" , ( ) => {
1945- for ( const c of controls ) {
1946- if ( c . input instanceof HTMLInputElement ) c . input . readOnly = false ;
1947- }
1948- controls [ 0 ] ?. input . focus ( ) ;
1949- manualToggle . remove ( ) ;
1950- } ) ;
1951- outer . append ( manualToggle ) ;
1966+ addManualToggle ( outer , controls , t ( "ui.enterCoordinatesManually" , "Enter coordinates manually" ) ) ;
19521967 } else {
19531968 outer . append ( label , row ) ;
19541969 noteId = appendNote ( outer , spec . note ? t ( `field.${ fieldId } .note` , spec . note ) : undefined ) ;
@@ -1967,6 +1982,12 @@ function renderField(
19671982 const errorId = appendError ( field ) ;
19681983 const describedBy = describedByOf ( noteId , errorId ) ;
19691984 if ( describedBy ) controls [ 0 ] ?. input . setAttribute ( "aria-describedby" , describedBy ) ;
1985+ // Auto-suggested and rarely meant to be hand-edited — id's own tooltip
1986+ // says as much ("never change it after publishing"). Reads as proposed
1987+ // text, not an invitation to type, until Edit unlocks it.
1988+ if ( fieldId === "slug" || fieldId === "id" ) {
1989+ addManualToggle ( field , controls , t ( "ui.editSuggested" , "Edit" ) ) ;
1990+ }
19701991 return field ;
19711992}
19721993
@@ -2013,6 +2034,35 @@ function clearField(
20132034 for ( const c of FIELD_SPECS [ id ] . controls ) onInput ( c . key , c . kind === "checkbox" ? false : "" ) ;
20142035}
20152036
2037+ /**
2038+ * Makes `controls` read-only (styled as plain text, not an input box — see
2039+ * `.field input[readonly]` in styles.css) and appends a small toggle that
2040+ * unlocks them for hand-editing. Used for values that already have a
2041+ * correct answer most of the time — map-driven coordinates, auto-suggested
2042+ * slug/id — so typing isn't the first thing invited.
2043+ */
2044+ function addManualToggle (
2045+ container : HTMLElement ,
2046+ controls : { input : HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement } [ ] ,
2047+ toggleLabel : string ,
2048+ ) : void {
2049+ for ( const c of controls ) {
2050+ if ( c . input instanceof HTMLInputElement ) c . input . readOnly = true ;
2051+ }
2052+ const toggle = document . createElement ( "button" ) ;
2053+ toggle . type = "button" ;
2054+ toggle . className = "link-button" ;
2055+ toggle . textContent = toggleLabel ;
2056+ toggle . addEventListener ( "click" , ( ) => {
2057+ for ( const c of controls ) {
2058+ if ( c . input instanceof HTMLInputElement ) c . input . readOnly = false ;
2059+ }
2060+ controls [ 0 ] ?. input . focus ( ) ;
2061+ toggle . remove ( ) ;
2062+ } ) ;
2063+ container . append ( toggle ) ;
2064+ }
2065+
20162066/** Wraps `inner` with a "×" button styled like a repeater row's, calling `onRemove` when clicked. */
20172067function wrapRemovable ( inner : HTMLElement , label : string , onRemove : ( ) => void ) : HTMLElement {
20182068 const block = document . createElement ( "div" ) ;
0 commit comments